Service Account Grants
Service accounts are M2M credentials (
clientIdclientSecretPOST /applications/:appId/users/:sub/api-keysEach grant is an FGA tuple:
. Same engine, cache, and revocation path as the rest of ZewstID's authorization.(service_account:CLIENT_ID) --[caller]--> (app:APP_ID)
Two scope kinds
A grant carries two scope sets that get treated differently:
| Kind | Where it lives | Who enforces it |
|---|---|---|
Platform scopes (api-keys:issueusers:invite | On the SA's Keycloak client (zewstid.scopes | ZewstID's own M2M endpoints via requireScopes() |
App-grant scopes (cal:readcal:write | On the FGA tuple's condition.scopes | Your app's resource server (when the SA issues user-API-keys carrying these scopes) |
If your SA needs to call ZewstID's user-api-keys endpoints AND your app's API:
- Add (etc.) to Platform Capabilities
api-keys:issue - Add (etc.) to Application Scopes
cal:read
Self-serve via the developer portal
1. Declare your app's scopes (once)
Navigate to Applications → [your app] → Settings → Declared Scopes. Add the scopes your resource server enforces:
cal:read cal:write cal:admin
These now appear in the SA-creation modal's "Application Scopes" picker for any SA bound to this app.
2. Mint an SA + grant in one step
Service Accounts → Create:
- Name:
cal-prod-runtime - Application: (dropdown — the apps you own)
cal-prod - Platform Capabilities: pick from the catalog (api-keys:issue, etc.)
- Application Scopes: pick from 's declared list
cal-prod - Grant Expires: optional date — the grant expires at end-of-day UTC; the SA itself doesn't
The SA is created in Keycloak, the FGA grant is written, and the secret is shown once.
3. Manage existing grants
Applications → [your app] → Service Accounts lists every SA granted to this app, with their per-grant scopes and expiry. Revoke individual grants from the same view.
Programmatic alternative (M2M)
For automation, the same flow is exposed as M2M endpoints (require
authz:write# Create the Keycloak SA via developer portal (no programmatic equivalent yet) # Then bind it to the app: curl -X POST https://api.zewstid.com/api/v1/authz/m2m/apps/cal-prod/service-accounts \ -H "Authorization: Bearer $M2M_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clientId": "sa_aBcD3FgH7iJk9LmN", "scopes": ["cal:read", "cal:write"], "expiresAt": "2027-01-01T00:00:00Z" }' # Revoke: curl -X DELETE https://api.zewstid.com/api/v1/authz/m2m/apps/cal-prod/service-accounts/sa_aBcD3FgH7iJk9LmN \ -H "Authorization: Bearer $M2M_TOKEN"
Errors return
400 unknown_scopeTime-bounded grants
expiresAtcondition.expires_atcallerOwnsApp()No sweeper job runs to delete expired tuples; they sit soft-deleted. Storage is fine; correctness is enforced at read time.
Cache caveat: the L2 (Redis) cache on positive results is 60s. A grant that expires has up to 60s of residual "allowed" answers on hot endpoints. For grants measured in hours/days/months, this is irrelevant. For short-TTL grants (5-min agent delegations), build that buffer into your
.expiresAt
Instant revocation
Tuple deletes (revoke) bust the L1 + L2 + cross-instance LRU caches synchronously via Redis pub/sub. Effective propagation: ~1 second across all api-gateway instances.
What the user-api-keys M2M flow checks
When an SA calls
POST /applications/cal-prod/users/:sub/api-keys- — does the SA's JWT carry this platform scope?
requireScopes('api-keys:issue') - — is there a live FGA grant
callerOwnsApp(SA, app)?(service_account:SA) --[caller]--> (app:cal-prod)
Each layer answers a different question. The grant says "this SA may act on behalf of this app." The scope says "this SA may perform this operation." Both required.
What's not yet shipped
- Per-grant scope editing — to change a grant's scopes/expiry, revoke and re-grant. Edit-in-place is a planned UX upgrade.
- Per-SA detail page showing all apps an SA is bound to. Use the per-app view for now (or query ).
/api/v1/authz/apps/:appId/service-accounts - Audience-specific token issuance — today the SA's JWT carries platform scopes only; per-grant scopes are in the FGA tuple, not the JWT. Future work: a Keycloak protocol mapper that filters the token's claims by so each token reflects only the relevant app's grant.
aud
Was this page helpful?
Let us know how we can improve our documentation