API Key Model

Agreed, not yet implemented. The model in The model is the agreed specification — the stance is ADR-0015, Accepted — and the code does not yet implement it. Current implementation describes what runs today, in the present tense, and Limitations of the current design lists the properties of that implementation that the model replaces. The two are kept apart so that no section reads as though unbuilt behaviour were running code. Decision history is in design-journal/2026-09/api-key-model.adoc.

1. Purpose

admin-service authenticates every machine caller with a key presented in the X-API-KEY header. One table shape serves every such caller, and until now nothing said what a key is: a front end forwarding its users' requests, a storefront plugin posting order updates, and an operator’s command-line wrapper all hold the same shape of row. That matters because a permission row means opposite things depending on who holds the key. For a plugin the row is the grant; for a front end whose users sign in, the same row is merged into every user’s authorities and escalates all of them.

This page classifies the keys into kinds, says what each kind identifies and needs, and states what each contributes to the security context. API-Key Injection at the Gateway covers how a front end attaches its key; this page covers what the key means once it arrives.

2. Vocabulary

Key

A row in api_key, identified to callers by its secret value and to operators by its description. The value is a credential and never appears in a document, a log line or an audit column; the description is the audit label.

Kind

The class of caller a key stands for. There are three: channel, integration and operator. A key has exactly one kind.

Channel

A registration_system row — the system an order or registration came through. Channels are the existing concept; this page does not add another. The Admin Portal’s channel is a platform-level row (type ADMIN, no organisation); a storefront’s channel is that storefront’s row, owned by its organisation.

Grant

An authority that permits something: a role such as ROLE_USER or ROLE_ADMIN, or the platform grant ROLE_SUPER_ADMIN. Grants come from a user’s own record when a JWT is present, and from a key’s permission rows when the key acts on its own authority.

Marker

An authority that identifies something and permits nothing: how the caller authenticated, which kind of key it holds, which channel it is. A gate may read a marker to tell callers apart. A marker never satisfies a role check and never widens which organisations a request may reach.

Key-only request

A request carrying X-API-KEY and no Authorization: Bearer header. The key is the whole principal.

Key-with-JWT request

A request carrying both. The JWT is the principal — the user’s organisation, person, account and roles come from it — and the key says which application forwarded it.

3. The model

3.1. Kinds of key

Kind Identifies Typical holder Ever accompanied by a user JWT?

Channel

One deployment of a front end that authenticates its own users and forwards their requests.

A registration portal deployment, a membership portal deployment, the Admin Portal.

Yes — on every proxied request after sign-in. Key-only calls happen only on the token-exchange endpoints under /auth/**, which run before a user exists, and on paths that need no authentication at all.

Integration

A system acting on its own authority inside one or more organisations.

A storefront’s payment plugin posting order status; a Model Context Protocol server reading one organisation’s events; a timing system pulling a roster.

Never. There is no user behind the call.

Operator

A human’s tool, with platform-wide intent and a person accountable for it.

A command-line wrapper an operator runs; an API client used for support; a scheduled job an operator owns.

Never.

An inactive key keeps its kind and its rows and authenticates nothing. Retiring a key is active = 'N', not deletion, so that audit labels keep resolving.

The Model Context Protocol server’s one-key-per-organisation arrangement is not a fourth kind. Each entry in its map is an ordinary integration key bounded to one organisation; the pattern lives in the client that selects among them, not in the keys.

3.2. What the schema holds

Platform-level facts about a key live on api_key. Per-organisation facts live on api_key_permission. Nothing platform-level is parked on an organisation row.

Table Column Meaning

api_key

kind

C, I or O — channel, integration, operator. Required. There is no default that a forgotten value could fall into; a row without a kind is a migration error, not a wildcard.

api_key

registration_system_id

Nullable link to the channel this key is. Required for a channel key whose channel is a single platform-level system (the Admin Portal). Null for a multi-tenant front end, whose channel is per tenant and arrives in the user’s JWT. Optional on an integration key that is a registration system in its own right (a storefront plugin), where it records which system holds the key. Never set on an operator key.

api_key

super_admin

Y or N, default N. The platform grant, mirroring org_user.super_admin for people. Platform-wide by definition, so it belongs to the key and not to any organisation row. Honoured on an operator key only; on any other kind it is ignored and reported.

api_key_permission

organisation_id, role

A tenancy bound and a role, for integration and operator keys only. The organisations are the set the key may act in. The roles are held by the key, not per organisation: the security context is a flat set of authorities, so a role on any row applies wherever the key acts. The two columns stay together because that is how the rows read to an operator, but the model does not pretend the role is scoped to its row.

A channel key has no permission rows. The kind says the key delegates to its users, so a row on such a key is a configuration error rather than a grant with an unusual meaning: it is never read into authorities, and its presence is reported at every authentication. Refusing such a key outright is deferred until keys can be managed through an administrative interface, where the row can be prevented rather than detected.

3.3. Authority naming convention

Three prefixes, each with a fixed meaning. The prefix is visible in the value itself, so a reader of a @PreAuthorize expression, a log line or a test can tell a grant from a marker without a list to consult.

Prefix Meaning May satisfy Must never

ROLE_

A grant. Held by a user from their own record, or by an integration or operator key from its permission rows and its platform flag.

Any role gate — hasAuthority('ROLE_…'), hasAnyAuthority(…), hasRole(…) — and the role hierarchy.

Be issued to a channel key, on any request.

KEY_

A marker for the kind of key that authenticated the request: KEY_CHANNEL, KEY_INTEGRATION, KEY_OPERATOR. Exactly one is present whenever a key authenticated the request, alone or with a JWT.

A gate that restricts who may call an endpoint by kind — for example, the token-exchange endpoints admit KEY_CHANNEL only.

Stand in for a role, or bypass organisation or person filtering. It is not a grant.

CHANNEL_

A marker for the channel a channel key represents: CHANNEL_ followed by the linked registration system’s type code, so the Admin Portal contributes CHANNEL_ADMIN. Derived at authentication from api_key.registration_system_id; never stored as a string.

A gate that restricts an operation to one front end, and the order-channel resolver.

Widen tenancy, or satisfy a role gate.

ROLE_SYSTEM is retired. It expressed "infrastructure acting for the platform", which is what the integration kind now says through KEY_INTEGRATION; a system acting on its own authority holds an integration key with organisation-bound rows, not a role that crosses organisations. ROLE_ADMIN stays a user-authorisation role bound to an organisation, on a key as on a person.

hasRole('X') is hasAuthority('ROLE_X'), so a marker under its own prefix can never satisfy a role check by accident. The protection this buys is not in the gates — the code base checks authorities by full name — but in the two places that manufacture and interpret roles: the authority builder, which may only turn a permission row into a ROLE_ value for a key whose kind carries grants, and the role hierarchy, which operates on the ROLE_ namespace alone.

3.4. What each kind contributes

Kind Presented alone Presented with a user JWT Refused

Channel

Authorities: KEY_CHANNEL, plus CHANNEL_<type> when the key links to a channel. No roles, no organisations. The request can reach the token-exchange endpoints and nothing else that is authenticated. Organisation resolution yields nothing: a channel key has no single organisation to fall back to, so an organisation-scoped operation on a key-only channel request fails closed.

Authorities: the JWT’s own, plus KEY_CHANNEL and CHANNEL_<type>. Organisation, person, account, linked organisations and roles are the JWT’s alone. The key adds which application and nothing about what the user may do.

Permission rows on a channel key: never read into authorities, and reported at every authentication. A key-only channel request reaching a role-gated endpoint is refused by the gate, because the key holds no role; one reaching an organisation-scoped operation fails closed, because the key holds no organisation.

Integration

Authorities: KEY_INTEGRATION, ROLE_<role> for each permission row. Organisations: the permission rows' set, which is what a request-scoped organisation header is validated against. When the set has exactly one member it is the request’s organisation.

Refused. An integration key never accompanies a user; a Bearer token alongside one is a misuse, and merging would hand the key’s roles to whoever holds the token.

A Bearer token; a key with no permission rows that reaches anything organisation-scoped (no organisations means no rows, never all rows).

Operator

KEY_OPERATOR, ROLE_<role> for each permission row, ROLE_SUPER_ADMIN when the platform flag is set, and the rows' organisations. Roles on an operator key are bound to their row’s organisation as a user’s are; reach across every organisation comes from the platform flag alone, never from a role.

Refused, for the same reason as an integration key.

A Bearer token.

3.5. Channel resolution

An order records the channel it came through. The resolver reads the security context in a fixed order and never guesses:

  1. CHANNEL_<type> is present — the caller is a channel key linked to a platform-level channel, and that channel is the answer. This outranks the JWT’s registrationSystemId claim because, for the Admin Portal, that claim names the operator’s sign-in registration system, which is a tenant and not a channel.

  2. Otherwise the JWT’s registrationSystemId claim names the channel. This is the multi-tenant front end’s case: the same deployment serves many registration systems, so its key links to none, and the tenant the user came through is carried in the token.

  3. Otherwise the channel is unknown and is left unset, with a warning that names the code path.

The Admin Portal’s sign-in configuration therefore keeps naming a registration system that has an organisation, because sign-in resolves an organisation’s user record; the key’s link names the organisation-less channel row. The two settings answer different questions.

3.6. Invariants

  1. A key never widens a user’s tenancy or roles. On a key-with-JWT request the principal’s organisation, person and roles are the JWT’s; the key contributes markers only.

  2. A marker never grants. KEY_ and CHANNEL_ authorities may be read to tell callers apart; no gate that permits an operation on the strength of a marker alone, and no organisation or person filter is bypassed on the strength of one.

  3. Token exchange is for channel keys. The endpoints that mint a user’s JWT admit KEY_CHANNEL only. An integration or operator key cannot mint a user.

  4. One key per deployment. A channel key identifies one front-end deployment; two deployments never share a value, or the channel cannot be told apart and neither can be rotated alone.

  5. Platform-level facts live on the key. Kind, channel link and the platform grant are columns of api_key. An organisation row never carries a fact about the platform.

  6. Key values are credentials. Never in a repository, a document, a log line or an audit column; the description is the audit label. Storage and rotation are in Credential Inventory.

3.7. A constructed example

A membership portal deployment holds channel key K1, linked to no registration system. A member signs in; the portal exchanges the identity provider’s claims for a JWT through a key-only call to /auth/token-exchange/oauth2, which K1 may make because it carries KEY_CHANNEL. Every later request carries K1 and the JWT. The security context holds the member’s organisation and person, ROLE_USER and the member’s tenant role from the JWT, and KEY_CHANNEL from the key. If K1 were given a row (organisation 7, ROLE_ADMIN), nothing would change for the member: the row is never read for a channel key, and every authentication of K1 reports it until it is removed. Reading it would mean every member holding ROLE_ADMIN.

A storefront’s payment plugin holds integration key K2 with one row (organisation 7, ROLE_USER) and a link to the storefront’s own registration system. It posts an order status update, key-only. The context holds KEY_INTEGRATION, ROLE_USER and organisation 7; the order it names must belong to organisation 7 or the update is refused. If the plugin ever sent a Bearer token as well, the request would be refused outright.

An operator’s wrapper holds operator key K3 with rows (organisation 1, ROLE_ADMIN) and (organisation 1, ROLE_USER) and super_admin = 'Y'. Key-only, the context holds KEY_OPERATOR, ROLE_ADMIN, ROLE_USER and ROLE_SUPER_ADMIN; ROLE_ADMIN is bound to organisation 1 as it would be for a user, and it is ROLE_SUPER_ADMIN that reaches every organisation. Audit columns written by K3 carry its description, never its value.

4. Current implementation

This section describes the code as it runs, so that the gap to the model can be seen.

  • api_key holds key_value, description, created_date and active. api_key_permission holds api_key_id, organisation_id (not null) and role (nullable, free text). There is no kind, no channel link and no platform flag.

  • APIKeyAuthenticationService rejects an unknown or inactive key and builds one flat authority list for every key alike: ROLE_API_KEY always, plus ROLE_<role> for every permission row, distinct. The organisation on each row is ignored when building authorities; the rows' organisations are carried on the resulting ApiKeyAuthentication.

  • ApiKeyFilter runs before bearer-token authentication. Key-only, the ApiKeyAuthentication becomes the context. Key-with-JWT, the JWT is decoded and a new user token is built from the JWT’s own claims with authorities = the JWT’s authorities plus the key’s; the key’s organisations are not carried across. A Bearer token that does not decode is refused rather than falling back to the key.

  • SecurityTenantContextHolder.getOrgId() reads the JWT’s organisation, and on a key-only request falls back to the key’s organisation when its rows name exactly one. Permitted organisations are the JWT’s primary and linked organisations plus, on a key-only request, the key’s rows. A key-with-JWT request therefore takes its tenancy from the JWT alone.

  • The order-channel resolver identifies the Admin Portal by the authority ROLE_ADMIN_PORTAL, which the Admin Portal’s key carries through a permission row; that row must name an organisation because the column is not nullable. The resolver reads the authority before the JWT’s registrationSystemId claim, for the reason given under Channel resolution.

  • The platform grant ROLE_SUPER_ADMIN reaches a key the same way: a permission row whose role is SUPER_ADMIN, parked on whichever organisation the operator chose.

  • The token-exchange endpoints under /auth/* require only that *some authentication succeeded. They read the registration system from the request body and never consult the key’s rows or authorities, so any active key of any kind can mint a user’s JWT for any registration system.

  • hasRole(…) is not used; every gate is hasAuthority or hasAnyAuthority by full name. One role-hierarchy rule exists: ROLE_ADMIN implies ROLE_USER.

5. Limitations of the current design

These are properties of the implementation above, stated so that a reader reasoning about a request today is not misled by the model.

  • A permission row on a front end’s key is a grant to every user of that front end. The merge adds the key’s roles to the user’s, and the rows' roles are not scoped to their organisation, so a single row (organisation 4, ADMIN) on a portal’s key gives ROLE_ADMIN to everyone who signs in through that portal, in every organisation.

  • Roles on a key are not bound to their row’s organisation. Authorities are one flat set, and ROLE_ADMIN from any row bypasses organisation filtering everywhere, as does ROLE_SYSTEM in the organisation-scope filter. A key with an ADMIN row for one organisation reaches all of them.

  • ROLE_API_KEY is a grant. Several dozen endpoint methods are gated hasAnyAuthority('ROLE_USER', 'ROLE_API_KEY'), and the shared organisation and person filters bypass filtering entirely for it. A key with no permission rows is therefore not "authenticated and nothing more": it reaches those endpoints, and on a request that names no organisation it reads unfiltered. On a key-with-JWT request the same bypass applies to the user, because the marker is merged in.

  • Nothing binds a key to its channel. The token-exchange endpoints admit any active key, and the only channel signal a key can carry is a role string on an organisation row.

  • Platform-level facts need an organisation. The Admin Portal marker and the platform grant both sit on organisation rows chosen for convenience; the organisation on those rows is noise that the schema forces.

  • Nothing requires a key to be unique to a deployment. Where several front ends present one value, the channel cannot be told apart from the key and no one of them can be rotated alone.

  • A key’s organisation set is ambiguous beyond one. The single-organisation fallback is what lets an integration or operator key act without naming an organisation on each request; a key with rows in two organisations must name one on every organisation-scoped request or is refused.