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.