Skip to main content

Service Account Grants

Service accounts are M2M credentials (

clientId
+
clientSecret
) that authenticate via OAuth client_credentials grant. To call your app's M2M endpoints (e.g.
POST /applications/:appId/users/:sub/api-keys
), an SA must be granted to the app.

Each grant is an FGA tuple:

(service_account:CLIENT_ID) --[caller]--> (app:APP_ID)
. Same engine, cache, and revocation path as the rest of ZewstID's authorization.

Two scope kinds

A grant carries two scope sets that get treated differently:

KindWhere it livesWho enforces it
Platform scopes (
api-keys:issue
,
users:invite
, …)
On the SA's Keycloak client (
zewstid.scopes
). Flows into the JWT.
ZewstID's own M2M endpoints via
requireScopes()
App-grant scopes (
cal:read
,
cal: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
    api-keys:issue
    (etc.) to Platform Capabilities
  • Add
    cal:read
    (etc.) to Application Scopes

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:
    cal-prod
    (dropdown — the apps you own)
  • Platform Capabilities: pick from the catalog (api-keys:issue, etc.)
  • Application Scopes: pick from
    cal-prod
    's declared list
  • 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
scope on the calling SA):

# 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_scope
if you reference scopes not in the app's declared catalog or platform list. Add them in app settings first.

Time-bounded grants

expiresAt
writes the timestamp into the FGA tuple's
condition.expires_at
. The check engine evaluates this on every read — once the timestamp passes, the grant disappears from
callerOwnsApp()
checks, list-objects results, list-users results, and the per-app SA tab.

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
, two layers must both pass:

  1. requireScopes('api-keys:issue')
    — does the SA's JWT carry this platform scope?
  2. callerOwnsApp(SA, app)
    — is there a live FGA grant
    (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
    aud
    so each token reflects only the relevant app's grant.

Was this page helpful?

Let us know how we can improve our documentation