Docs / Architecture / Security model
Explanation For operators

Security model

What the application protects is the record — who asked for what, who approved it, and what shipped — rather than the warehouse data itself, which is only ever a query away from anybody the warehouse's own grants already let in. Most of what follows exists to make sure a write cannot happen without a name behind it, and that a credential cannot leak sideways into something it was never meant to open.

Doors fail closed

Five identity modes, one DPF_IDENTITY, and dpf/web/gate.py repeats the same shape for whichever is on: closed when it is not configured, never open. Without SECRET_KEY and an allowlist (or a link key, under link), the application cannot tell a reader from a stranger, and the honest answer to "I cannot tell" is refusal, not admission. install, install_link and install_both each abort with a 503 naming what is missing, rather than falling through to whichever door happens to be easiest to satisfy; the equivalent check for entra lives beside it in dpf/web/entra.py and stops the application starting at all.

One opening is deliberate and narrow: gate.public() lets the documentation and the static files it needs answer with no door, and neither reads the record — the reference pages are generated from code, never from a product's data, so publishing the docs publishes nothing about what anybody has built with them.

Every write has to prove where it came from

A hook on every request refuses a POST, PUT, PATCH or DELETE whose Origin or Referer disagrees with the host being asked — a page somebody happens to have open in another tab must not be able to approve a release. There is no token to synchronise, because the forms are all server-rendered and same-origin already answers the question; a request with neither header, which is every curl call and the CLI, is let through, because neither is a browser that could have been tricked into sending one.

urls.py holds exactly one check for this, not several. same_origin is what the write-refusal above compares against; ours() adds local_path on top of it and is what every redirect in the application calls to decide where a sign-in may send somebody next. There were four copies of this logic once, and they disagreed: one accepted /\evil.example, which some browsers read as //evil.example and follow off the application entirely. A next= parameter, a hidden field and a Referer header are all written by whoever made the request, so redirecting to any of them unchecked is a phishing page the application built for the attacker.

Machines get a narrower door, and it cannot write

A service token is 32 random bytes, shown once and kept only as its SHA-256 — there is nothing to guess, so there is nothing a slow hash would protect, and every API call would pay for one anyway. What a token opens is /api/v1 and nothing else: the API is read-only by construction, with no scope that writes, so a leaked token can see what any signed-in person could already see and cannot approve, draft or promote anything on its strength.

What reaches the model is configuration, not a habit

DPF_LLM_DATA decides what the three agents may send to Anthropic: everything, a redacted profile with no value from any row, or nothing at all. The redaction is allow-listed rather than deny-listed, and enforced in the one function that gathers grounding — a profiler that starts reporting a new statistic tomorrow is left out by default rather than shipped by accident. That makes it a control a security review can actually finish: the question "does customer data leave this deployment" has one function to read, not three call sites to each get right.

Secrets go in, and never come back out

Anything that authenticates — a warehouse token, a tracker's personal access token, ANTHROPIC_API_KEY — is write-only from the Connections page. settings.SECRET names them; the page reports each as set or not set and never renders the value, so an administrator with a browser open cannot read back a credential somebody else typed in, and screen-sharing the page leaks nothing. Clearing or replacing one is the only edit there is. See Rotate a secret.

The box itself

The systemd unit runs the application as an unprivileged user with NoNewPrivileges, PrivateTmp and ProtectSystem=strict, able to write only its own var/ directory, and bound to loopback so the only way in is through the reverse proxy that terminates TLS in front of it. None of this is specific to this application — it is what running any small service on a shared box responsibly looks like — which is the point: the controls worth arguing about are the ones above, and the box underneath them should never be where a review finds the gap instead.

Documentation for version 0.2.0 Reviewed 2026-10-04 Covers dpf/web/gate.py, dpf/web/urls.py, dpf/privacy.py, dpf/settings.py