Skip to content

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.

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 JWTAuthentication themselves and DRF authenticates inside the view, so a middleware sees an anonymous user on every Bearer call.
  • Storefronts send the customer’s JWT in Authorization and the shop key in X-API-KEY on the same request. An application token cannot live in Authorization.

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.

  • Areas. A catalogue in code: module areas (pim.products, checkout.orders, access.manage, …), each with read and usually write. 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 an auth.Group. No per-user overrides, no deny rules.
  • Staff accounts. Administrators create them through the admin API (POST api/access/v2/admin/staff/, needs access.manage:write), which the CMS uses under Access → Staff. Superusers still come from createsuperuser.
  • The gate. AccessGateMiddleware resolves the route first (a 404 stays a 404), then acts only on the admin set: routes under an admin/ segment or api-admin/, plus views with an IsAdminUser/IsSuperUser permission class and a short list of named exceptions. The two GDPR erase routes under api-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>:read or <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_area attribute, else the owning module’s AppConfig rules, 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.bypass audit row, written after the response with its status.
  • Modes. ACCESS_GATE_MODE is enforce (default), observe (log what would be refused, refuse nothing) or off. It is a setting only: no API or database row changes it. Any other value is enforced.
  • 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-KEY is 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-KEY at all. Admin scopes for machines are a later, separate decision.
DecisionWhat it means
access.manage is built-in onlyonly the Administrator role carries it; a custom role asking for it gets 400 ACCESS_MANAGE_RESERVED
Existing staff become Managermigration 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 themselvesimported 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 importeda 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 preflightmanage.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 viewsaccess_area on a view wins over AppConfig rules, which win over the access defaults
OpenAPI staff-onlythe service serves the schema, Swagger and ReDoc to staff unless API_SCHEMA_PUBLIC is set
DRF_NUM_PROXIES per installationcopied 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 limitedon 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 lifetimean 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 onlythe CMS replaced the Django admin for clients. Every role is refused there, Administrator included (403 ACCESS_DENIED, superuser only); login and logout stay reachable
Versionsaccess ships as 1.0.0; the key modules release as majors; Volkanos as Platform 4.0
No staff MFA yeta second factor for staff is planned; it is not part of this release
  • 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_areas on the AppConfig, access_area on the views) at its next regular release; manage.py check lists 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_ROUTE to everyone but superusers. That is why the upgrade preflight exists and why every module proves its coverage in CI with assert_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-key header 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.
AlternativeWhy not
A shared DRF permission class in every modulea 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 tokensstorefronts 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.Permissionone 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

Planned separately, not built: staff MFA, staff SSO, admin scopes for application tokens, throttling of the checkout storefront routes and audit retention.

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.