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 |
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_systemrow — 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 (typeADMIN, 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_USERorROLE_ADMIN, or the platform grantROLE_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-KEYand noAuthorization: Bearerheader. 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? |
|---|---|---|---|
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 |
|
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. |
|
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 |
|---|---|---|
|
|
|
|
|
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. |
|
|
|
|
|
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 |
|---|---|---|---|
|
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 — |
Be issued to a channel key, on any request. |
|
A marker for the kind of key that authenticated the request: |
A gate that restricts who may call an endpoint by kind — for example, the token-exchange
endpoints admit |
Stand in for a role, or bypass organisation or person filtering. It is not a grant. |
|
A marker for the channel a channel key represents: |
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: |
Authorities: the JWT’s own, plus |
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: |
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 |
|
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:
-
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’sregistrationSystemIdclaim because, for the Admin Portal, that claim names the operator’s sign-in registration system, which is a tenant and not a channel. -
Otherwise the JWT’s
registrationSystemIdclaim 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. -
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
-
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.
-
A marker never grants.
KEY_andCHANNEL_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. -
Token exchange is for channel keys. The endpoints that mint a user’s JWT admit
KEY_CHANNELonly. An integration or operator key cannot mint a user. -
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.
-
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. -
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_keyholdskey_value,description,created_dateandactive.api_key_permissionholdsapi_key_id,organisation_id(not null) androle(nullable, free text). There is no kind, no channel link and no platform flag. -
APIKeyAuthenticationServicerejects an unknown or inactive key and builds one flat authority list for every key alike:ROLE_API_KEYalways, plusROLE_<role>for every permission row, distinct. The organisation on each row is ignored when building authorities; the rows' organisations are carried on the resultingApiKeyAuthentication. -
ApiKeyFilterruns before bearer-token authentication. Key-only, theApiKeyAuthenticationbecomes 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’sregistrationSystemIdclaim, for the reason given under Channel resolution. -
The platform grant
ROLE_SUPER_ADMINreaches a key the same way: a permission row whose role isSUPER_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 ishasAuthorityorhasAnyAuthorityby full name. One role-hierarchy rule exists:ROLE_ADMINimpliesROLE_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 givesROLE_ADMINto 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_ADMINfrom any row bypasses organisation filtering everywhere, as doesROLE_SYSTEMin the organisation-scope filter. A key with anADMINrow for one organisation reaches all of them. -
ROLE_API_KEYis a grant. Several dozen endpoint methods are gatedhasAnyAuthority('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.
6. Related
-
API-Key Injection at the Gateway — how a front end attaches its key, and rotation.
-
ADR-0015: API keys are classified by kind — the stance this page specifies.
-
ADR-0009: Organisation scope is server-derived — the rule a channel marker inherits.
-
Multi-Tenancy Architecture — organisations, tenants and registration systems.
-
JWT Authentication and Token Flow and OAuth2/OIDC Authentication — the user side of a key-with-JWT request.
-
MCP Pattern — the per-organisation integration keys.
-
Payment Integration — the storefront plugin’s integration key.
-
Credential Inventory — where each key’s value is held.