ADR-0015: API keys are classified by kind, and a key that accompanies a user grants nothing

Status

Accepted (amended the same day, before publication: decision 9 added)

Date

2026-09-26

Deciders

Christhonie Geldenhuys (architect); drafted by the research session recorded in the design journal, accepted 2026-09-26

Related

ADR-0009; API Key Model; API-Key Injection at the Gateway; design-journal 2026-09/api-key-model.adoc

1. Context

admin-service authenticates machine callers with an X-API-KEY header resolved against one table shape: a key row and zero or more permission rows, each naming an organisation and a role. The authority builder flattens every row’s role into one set for every key alike, always adds ROLE_API_KEY, and the filter merges that set into the user’s authorities whenever a key arrives together with a user’s JWT.

Three classes of caller hold such keys, and the merge means opposite things for them. A storefront’s payment plugin and a Model Context Protocol server act on their own authority with no user behind them; their rows are the grant and the tenancy bound, and the flattening is at worst a fiction about per-organisation scoping. A front end that authenticates its own users — a registration portal, a membership portal, the Admin Portal — attaches its key to every proxied request, so a row on its key is granted to every user who signs in through it. That is how one row, (organisation 4, ADMIN), gave every Admin Portal operator ROLE_ADMIN in every organisation, and with it five person-lookup and communication-log endpoints no portal screen calls.

Fixing that exposed the absence of a rule. The Admin Portal’s identity as a channel — the fact the order-channel resolver needs, and the only signal that distinguishes the portal from a registration deployment — had just been recorded as another role row, because there was nowhere else to put it. The platform grant ROLE_SUPER_ADMIN reaches operator keys the same way. Both sit on an organisation chosen for convenience, because the permission row’s organisation column is not nullable. And ROLE_API_KEY, introduced to distinguish key callers from users, is treated as a grant by several dozen endpoint gates and by the shared organisation and person filters, so a key with no rows at all is far from "authenticated and nothing more".

Two of the three front ends already run with no permission rows and work, which shows that a delegating key needs none. The forces: an integration key must keep its rows as its only bound; a channel key must never grant; the channel marker must remain something a client cannot assert (ADR-0009’s stance); "unlimited" and "unset" must never share a representation; and the schema must be able to say which of these a key is, rather than leaving it to be inferred from which rows happen to exist.

2. Decision

  1. Every key has a kind, recorded on the key. api_key.kind is one of channel, integration or operator, required, with no default. A channel key identifies one deployment of a front end that authenticates its own users. An integration key identifies a system acting on its own authority inside the organisations its rows name. An operator key identifies a human’s tool with platform-wide intent.

  2. A channel key contributes markers only. It carries no permission rows; rows found on one are never read into authorities and are reported at every authentication. Alone it contributes KEY_CHANNEL and, when linked to a platform-level channel, CHANNEL_<type>; with a user’s JWT it adds exactly those two to the user’s own authorities. The user’s organisation, person and roles are the JWT’s alone.

  3. An integration or operator key is never merged with a user. Presented with a Bearer token it is refused. Presented alone, its rows are its roles and its tenancy bound, and its platform grant is a column of the key.

  4. Markers live under their own prefixes. ROLE_ is a grant and may satisfy role gates and the role hierarchy. KEY_ names the kind of key that authenticated the request and may restrict who may call. CHANNEL_ names the channel a channel key represents and may restrict an operation to one front end and feed the order-channel resolver. Neither marker prefix satisfies a role gate, widens tenancy, or bypasses a filter. ROLE_API_KEY and ROLE_ADMIN_PORTAL are retired in favour of KEY_<kind> and CHANNEL_<type>.

  5. The channel is a link, not a string. api_key.registration_system_id, nullable, names the registration system a key is. Required on a channel key whose channel is platform-level; null on a multi-tenant front end, whose channel is per tenant and arrives in the JWT; optional on an integration key that is a registration system. The CHANNEL_ authority is derived from the linked row’s type at authentication and never stored.

  6. Platform-level facts are columns of the key. Kind, channel link and the platform grant (api_key.super_admin, mirroring org_user.super_admin) live on api_key. The flag is honoured on an operator key only. The permission row’s organisation column stays not-null, because every row is now a per-organisation fact about a key that acts on its own authority.

  7. Token exchange admits channel keys only. The endpoints that mint a user’s JWT require KEY_CHANNEL.

  8. One key per deployment. A channel key is never shared between deployments.

  9. ROLE_SYSTEM is retired; roles on a key are organisation-bound. The integration kind is what ROLE_SYSTEM expressed. ROLE_ADMIN stays a user-authorisation role bound to an organisation, on a key as on a person. Reach across every organisation for a key comes from the platform flag on an operator key, never from a role.

3. Consequences

3.1. Positive

  • A permission row has one meaning: a grant and a bound for a key that acts on its own authority. It can no longer escalate a front end’s users, and a stray row on the next front end added is reported at every authentication instead of silently widening every operator.

  • The Admin Portal’s channel is a foreign key to the row the resolver already looks up, with no organisation parked on it, and the same link serves any future single-channel front end.

  • The platform grant for keys takes the shape it already has for people.

  • Grant and marker are distinguishable in the value, so a @PreAuthorize expression, a log line and a test read correctly without an allow-list to maintain.

  • Retiring ROLE_API_KEY as a grant, which the role-model work already owns, has a defined successor: kind markers that restrict who may call and never what they may do.

  • An integration or operator key can no longer mint a user’s token.

3.2. Negative

  • Three columns and a migration in every environment, including the stage seed tables that the nightly clone restores from; a key whose kind is not set cannot authenticate, so the migration is a prerequisite of the release that enforces the rule.

  • Refusing a Bearer token with an integration or operator key is a new failure mode that takes a misusing caller down until it is corrected — the intended trade, loud and safe over quiet and wide. A channel key with rows is reported rather than refused, so the misconfiguration persists in the data until an operator removes it; see Alternative F.

  • Front ends that today share one key value must each receive their own before the channel link can mean anything.

  • Integration keys that today rely on ROLE_API_KEY alone lose access when that grant is retired and must be given the role rows they always should have had.

3.3. Neutral

  • Per-organisation roles on a key are not scoped to their organisation today; the model binds them as a user’s are, and the role-model work that reconciles the organisation filters implements it. Until then ROLE_ADMIN on any row still reaches every organisation.

  • The single-organisation fallback for key-only requests is unchanged for integration and operator keys, and becomes vacuous for channel keys, which hold no rows.

  • The gateway injection mechanics, session-held JWT and rotation procedure are unchanged.

4. Alternatives Considered

4.1. Alternative A: A marker column on the key, and an allow-list of marker roles

The first proposal: keep the rows, add a "delegating" flag to the key, and hold a list of role strings that are markers rather than grants. Rejected because it leaves two unlike things in one column and moves the distinction into a list someone must maintain, where a new marker added without a list entry becomes a grant. The kind column keeps the flag’s positive marker; the prefix convention replaces the list with something visible in the value.

4.2. Alternative B: A nullable organisation on the permission row for marker rows

Let a row with no organisation mean "platform-level" so that a marker or the platform grant can sit on a row without an organisation. Rejected because it makes "unlimited" and "unset" share a representation on the very table that bounds tenancy, and because the marker and the grant are facts about the key, not rows of it.

4.3. Alternative C: The channel as a stored authority string

Keep identifying the Admin Portal by a role-shaped string granted through a row, renamed under a marker prefix. Rejected because the channel already exists as a registration_system row that the resolver looks up by type; a string duplicates a fact the schema holds, and cannot name a specific storefront where a single-channel front end would need it. A link is a foreign key, carries no organisation, and yields the marker by derivation.

4.4. Alternative D: One kind with a delegating flag

Two kinds — delegating and autonomous — rather than three. Rejected as the primary shape because integration and operator keys differ in what an operator expects of them: an operator key has a person accountable for it, is rotated on that person’s departure, and carries platform-wide intent; an integration key belongs to a system and is bounded by its organisations. Folding them loses that in the schema while saving nothing in the code, since both build authorities the same way. It would become the right choice if the two were found to need identical treatment in every consumer.

4.5. Alternative E: Drop the organisation column from the permission row

Since roles on a key are flat, drop the tenancy bound and rely on roles alone. Rejected because six live integration keys are bounded by nothing else, and dropping the column would turn each into a platform-wide credential.

4.6. Alternative F: Refuse a channel key that carries permission rows

Fail closed on the misconfiguration: a front end whose key has acquired a row stops authenticating until the row is removed. Deferred rather than rejected. A row can only arrive by hand today, and a hand-made data error would take a whole portal down; ignoring the rows and reporting them at every authentication removes the escalation without the outage. Refusal becomes the right choice once keys are managed through an administrative interface that prevents the row from being written at all.

5. References

  • Design journal: design-journal/2026-09/api-key-model.adoc (opened 2026-09-26; research session 2026-09-26)

  • Specification: API Key Model

  • Code: admin-service/src/main/java/za/co/idealogic/event/admin/security/apikey/APIKeyAuthenticationService.java (the authority builder), …​/security/apikey/ApiKeyFilter.java (the merge), …​/security/SecurityTenantContextHolder.java (tenancy resolution), …​/service/OrderChannelResolver.java (the marker’s consumer), …​/security/AuthoritiesConstants.java