Service Identities
Every mw_ API token used to be issued for the signed-in person and nobody else: the personal
token tab, POST /api/tokens, the OAuth /token exchange and the MCP back-connection all mint for
AccessService.Context. A caller that is not a person — a name-check endpoint, a CI job, a partner
integration — therefore had to borrow a person's token: its writes were audited as that person and it
carried that person's whole reach. A service identity is the caller's own principal.
The model
| Record | Admin/_ServiceIdentity/{objectId}, node type ServiceIdentity (ServiceIdentity content: description, issued by/at, revoked flag). |
| Object id | svc-{name} — the node id, and the AccessContext.ObjectId its tokens authenticate as. ServiceIdentity.ObjectIdFor("Name Check") = svc-name-check. |
| Tokens | ordinary ApiToken rows at Admin/_ServiceIdentity/{objectId}/ApiToken/{hashPrefix}, indexed at ApiToken/{hashPrefix} like every token, carrying ServiceIdentityPath. Shown once, stored hashed, optional expiry, LastUsedAt stamped. |
| Grants | an ordinary AccessAssignment at {scope}/_Access/{objectId}_Access with AccessObject = objectId. The permission evaluator treats it like any subject. |
| Audit | CreatedBy / LastModifiedBy carry the service id — never the admin who issued the token. |
The records live in the Admin partition for the reason BuildPrincipal does: only a global admin
can write there, so only a global admin creates an identity, issues or revokes its tokens, or revokes
the identity — and the subject can never write its own record.
Managing them — the Admin app
Admin app → People & sign-in → Service identities (platform admins only; the area re-checks the gate for direct links). Create an identity, issue a token (shown once), rotate a token (a new token with the same label and term length is minted first, then the old one is revoked), revoke a token, grant access at a path with a role, revoke the identity.
A grant sets the service's role at that scope — re-granting a different role replaces the
previous one (one role per scope for a machine principal; compose several on the node's Access
Control tab). A grant is written as the admin. A global admin is not a data superuser, so the grant succeeds
exactly where that admin may grant at the target scope — the tab does not widen anyone's reach. A
scope owner who is not a platform admin grants a service the same way they grant anyone: an
AccessAssignment whose subject is the svc-… id.
The verbs behind the tab are ServiceIdentities.Create / Revoke / Grant / Rotate and
ApiTokenService.CreateServiceToken / GetTokensForService / RevokeToken (memex), which the tests drive
directly.
Validation — both paths
A token carrying ServiceIdentityPath authenticates only while its record exists and is not
revoked, read from the same authoritative store as the token on every use. Revoking the identity
therefore stops every token it holds on the next request, on every replica, without touching the
tokens. Both validation paths apply the same predicate (ServiceIdentity.Refuse):
ApiTokenService.Validate(theApiTokenauthentication handler — storage-direct): a read fault isUnavailable(503, retryable), neverInvalidand neverValid.ApiTokenNodeType.HandleValidateToken(the request middleware's hub path): answersValidateTokenResponse.IsService = true, which becomesAccessContext.IsService.
Both read the record from the authoritative store: a record that was deleted rather than revoked reads as absent and refuses the token definitively (401), exactly like a revoked one; only a store that cannot answer yields the retryable 503.
A token that names a svc-… id without an identity path was not minted by the service surface and
is refused on both paths.
Five guards that keep a service a service
- Issuing for someone else names only a service.
CreateServiceTokenrefuses a non-svc-id, an absent record and a revoked identity before writing anything;CreateToken(every person surface) refuses asvc-id. There is still no way to mint a token for another person. - No person can become a service. Onboarding refuses a username starting
svc-; the request middleware refuses — as anonymous — any session that resolves to asvc-object id without having been authenticated by that service's token (an e-mail whose local part readssvc-…, a dev login). The principal-kind claim is honoured only on an identity of theApiTokenscheme, so a cookie or an external provider cannot assert it. - No service is treated as a person. No onboarding redirect, no login record in a user partition, no logon actions.
- A service never holds global admin.
hub.IsGlobalAdmin(userId)answersfalsefor asvc-id whatever the grants say;ServicePrincipalAdminGuardrefuses anAccessAssignmentin the Admin partition whose subject is a service. - A service writes nothing in the Admin partition — not its own record, not a token, not a grant — even if a group membership gave it rights there.
What it does not do
- A service inherits the
Publicbaseline every authenticated caller gets, like any signed-in principal. Grant nothing toPublicyou would not grant a service. - The access-control subject picker lists Users and Groups; a service is granted from the Service
identities tab or by writing its
svc-…id as theAccessObject. - Per-caller rate limiting is not part of this change.