Access and API Tokens
Status: accepted (2026-09-30, amended 2026-10-02 and 2026-10-03). Implementation: entirius-django-access,
released with Platform 4.0 and a major of each key module (their key handling is incompatible for
integrators); the versions are listed under Versions. A release before Platform 4.0
behaves as described under Context.
Context
Section titled “Context”Before this decision Volkanos had two authorization stories, and neither held up.
Staff. Every admin route in every module asked one question: is this user staff? A staff user
could do everything, from editing a product description to exporting customer data. There was no role, no
read-only user and no audit. The question itself was asked two ways: some modules shipped a local IsAdminUser
(staff or superuser), others used DRF’s own (staff only), and contentdb ran its own superuser bypass. The handbook described
the local variant as if it were the only one. The admin CMS had no user concept at all: it checked which modules
were installed, never who was logged in, so a customer account could open it and see empty panels.
Machines. Every key module had its own key mechanism (checkout had two). Every one stored the live secret in
plaintext, matched it with an unindexed filter(key=...), never expired and could not be rotated without a
coordinated deploy. Half of them ship to browsers by design (storefront carts, contact forms, booking, newsletter);
the other half delete customer data or touch stored cards. Nothing told the two halves apart.
Two facts shaped the design:
- A Django middleware never sees who is calling. Admin views declare
JWTAuthenticationthemselves and DRF authenticates inside the view, so a middleware sees an anonymous user on every Bearer call. - Storefronts send the customer’s JWT in
Authorizationand the shop key inX-API-KEYon the same request. An application token cannot live inAuthorization.
Decision
Section titled “Decision”One new module, entirius-django-access, owns staff permissions and application tokens. It is a core module
like entirius-django-utils: every Volkanos service will require it.
Staff: a gate in front of the admin API
Section titled “Staff: a gate in front of the admin API”- Areas. A catalogue in code: module areas (
pim.products,checkout.orders,access.manage, …), each withreadand usuallywrite. Write implies read. Deleting a SKU is its own write-only area (pim.product_delete). - Roles. Four built-in roles computed from the catalogue at run time: Administrator (everything), Manager
(everything except
access.manage), Editor (writes content, PIM catalogue, FAQ and mail templates; reads the rest), Viewer (reads everything except access management, exports no PII). Custom roles hold validated permission keys. A grant gives a role to an active staff user or to anauth.Group. No per-user overrides, no deny rules. - Staff accounts. Administrators create them through the admin API (
POST api/access/v2/admin/staff/, needsaccess.manage:write), which the CMS uses under Access → Staff. Superusers still come fromcreatesuperuser. - The gate.
AccessGateMiddlewareresolves the route first (a 404 stays a 404), then acts only on the admin set: routes under anadmin/segment orapi-admin/, plus views with anIsAdminUser/IsSuperUserpermission class and a short list of named exceptions. The two GDPR erase routes underapi-admin/are key routes, not admin. It authenticates on its own with the same authenticators the view would run (SimpleJWT and DRF sessions only), maps the route and method to<area>:reador<area>:write, and refuses or lets the view decide. It never grants: the view’s own checks still run. Outside the admin set it does nothing, so the shop pays one memoized lookup. - Where a route’s area comes from. The view’s
access_areaattribute, else the owning module’sAppConfigrules, else the access module’s defaults for the modules that exist today. Rules only ever apply to their own module’s routes. - Superusers bypass the permission check. Every superuser write leaves one
gate.bypassaudit row, written after the response with its status. - Modes.
ACCESS_GATE_MODEisenforce(default),observe(log what would be refused, refuse nothing) oroff. It is a setting only: no API or database row changes it. Any other value is enforced.
Machines: application tokens in X-API-KEY
Section titled “Machines: application tokens in X-API-KEY”- An application holds tokens. A token is
ent_api_plus 43 random URL-safe characters, shown once. The database keeps its SHA-256, a 12-character prefix and the last four characters. - Sent in
X-API-KEY;X-API-ADMIN-KEYis accepted as an alias so existing callers keep their header. - Nine scopes shared by every key route. Four are publishable (they reach browsers), five are secret. One token never mixes the two.
- An optional channel pin: a pinned token passes only where the route’s channel equals the pin.
- Key modules call
verify_api_key(request, scope): one uncached query by hash, one answer (None) for every kind of failure, so revocation applies on the next request and the API is no oracle. - Tokens never open an admin route. The gate does not read
X-API-KEYat all. Admin scopes for machines are a later, separate decision.
Further Rules
Section titled “Further Rules”| Decision | What it means |
|---|---|
access.manage is built-in only | only the Administrator role carries it; a custom role asking for it gets 400 ACCESS_MANAGE_RESERVED |
| Existing staff become Manager | migration 0002 grants Manager to every active non-superuser staff user; access management stays with superusers until someone is granted Administrator |
| Legacy keys never expire by themselves | imported keys keep working until a team rotates, expires or revokes them; access_legacy_report shows who still uses which key; access_purge_legacy_keys deletes the plaintext rows on demand |
| Mixed legacy secrets are not imported | a secret found in both a browser key and a secret key gets no token; it is reported for rotation and access_import_legacy_keys --check fails |
| Upgrade preflight | manage.py access_routes --unmapped before enforcing; check --deploy errors (E011) when the gate would enforce over unmapped admin routes; installations with their own apps deploy in observe first |
| Ownership on views | access_area on a view wins over AppConfig rules, which win over the access defaults |
| OpenAPI staff-only | the service serves the schema, Swagger and ReDoc to staff unless API_SCHEMA_PUBLIC is set |
DRF_NUM_PROXIES per installation | copied into DRF’s NUM_PROXIES; check --deploy errors (volkanos.E001) while it is unset, because every per-IP throttle trusts X-Forwarded-For without it; 0 means no proxy |
| Failed logins limited | on api/token/ and the CMS login route customer/tokens/, per user and address, per address and per login (50 an hour from any address); successful logins never count |
| No maximum token lifetime | an expiry is optional for every token and has no maximum; only a past date is refused. Every token shows its age, and the API and CMS flag rotation_due once an active token is ACCESS_TOKEN_ROTATION_DAYS old (default 365). A recommendation: nothing is refused for age |
| Django admin for superusers only | the CMS replaced the Django admin for clients. Every role is refused there, Administrator included (403 ACCESS_DENIED, superuser only); login and logout stay reachable |
| Versions | access ships as 1.0.0; the key modules release as majors; Volkanos as Platform 4.0 |
| No staff MFA yet | a second factor for staff is planned; it is not part of this release |
Consequences
Section titled “Consequences”- No module had to release for the gate to work. The access defaults map every route-owning module. Each
module moves its mapping into its own code (
access_areason theAppConfig,access_areaon the views) at its next regular release;manage.py checklists the ones still on the defaults (django_access.I001). The Django Module Standard has the rule for new code. - An unmapped admin route is refused, with 403
UNMAPPED_ROUTEto everyone but superusers. That is why the upgrade preflight exists and why every module proves its coverage in CI withassert_routes_covered. - Every admin request authenticates twice: the gate decodes the JWT and loads the user, then the view does the same through DRF. The gate caches its own result on the request, so the cost is one repeat, not one per check.
- Legacy keys live on as tokens with the same secret. No caller changes on upgrade day, including an integration
that still sends the legacy
x-api-admin-keyheader and cannot change fast. The price: legacy tokens carry no expiry until a team sets one, and the plaintext rows stay in the module tables until someone purges them. Rotation is each team’s policy, not a deadline in code. - Superusers stay all-powerful, now with an audit trail of their writes, and they are the only Django admin users. A superuser is created from the command line only; the API never grants it.
- The CMS learns who is logged in. It reads
GET /api/access/v2/me/, hides panels the user cannot read and disables forms the user cannot write. The server still decides: a hidden button is convenience, the 403 is the control.
Alternatives Rejected
Section titled “Alternatives Rejected”| Alternative | Why not |
|---|---|
| A shared DRF permission class in every module | a release of every module before anything is protected, and it still misses non-DRF routes such as the Django admin. A coverage test over the resolver works with the middleware and needs no release |
| OAuth2 client credentials (short-lived JWTs) | one service and one database per installation: statelessness buys nothing, and revocation would lag by the token lifetime. Public widgets cannot hold a client secret anyway. An OAuth2 adapter that exchanges the same application secret can come later |
Authorization: Bearer for application tokens | storefronts already send the customer JWT there on the same request, and SimpleJWT raises on a Bearer value that is not a JWT |
Django’s auth.Permission | one model and one migration per entry, no read/write semantics per area, and a default row set that already means nothing. Roles keep string keys in their own tables; auth.Group stays usable as a grant subject |
Not In This Decision
Section titled “Not In This Decision”Planned separately, not built: staff MFA, staff SSO, admin scopes for application tokens, throttling of the checkout storefront routes and audit retention.
Further Reading
Section titled “Further Reading”The module documents itself in its repository:
entirius-django-access/docs (concept.md,
upgrade.md, module-authors.md, operations.md). Creating,
sending and rotating tokens: Application Tokens.
How a change in PIM reaches the storefront read model: Signal-Driven Sync.