Service accounts in CRM
A service account is a non-human caller that authenticates with identity using an API key and acts on its own. The token identity issues for it looks structurally the same as a user token, with two material differences:
- The token's
auth_userpayload has no human fields (no email, no display name) and carries the API key's owning user asprovisioned_byfor audit purposes. - The token carries a
scopes[]claim — an allow-list of permission strings the bearer is permitted to exercise.
CRM enforces the scope claim in CheckPermission before evaluating
policies (cross-repo audit #74 / #143 / #144).
Scope syntax
A scope is a permission string. The full grammar:
<scope> ::= <segment> ("." <segment>)*
<segment> ::= <plain> | "*"
<plain> ::= [a-z_]+
Examples:
| Scope | Matches |
|---|---|
tenant.acme.crm.tasks.view | exactly this permission |
tenant.acme.crm.tasks.* | every action on tasks in acme |
tenant.*.crm.tasks.view | view tasks on any tenant |
tenant.*.crm.* | any CRM action, any tenant (admin scope) |
* | bypass — never use except internally |
A wildcard segment matches exactly one segment of the requested
permission. A trailing * is a prefix wildcard that covers any
deeper segments — so tenant.acme.crm.* matches
tenant.acme.crm.tasks.view and also tenant.acme.crm.tasks.export.
Gate placement
[client] ── Bearer token ──▶ [VerifyOAuthToken] ──▶ [CheckPermission]
│ │
│ ├─ (1) admin bypass
│ ├─ (2) tenant membership
│ ├─ (3) SERVICE-ACCOUNT SCOPE GATE ← here
│ └─ (4) policy evaluation
│
└─ auth_user[scopes] populated
Scope-gate is third in the chain so:
- Admin tokens skip it (they get unrestricted access anyway).
- Tenant-foreign tokens fail earlier with
tenant_not_a_member. - Failing the scope gate gives a structured 403:
{
"message": "The service-account token does not include this permission in its scopes.",
"error": "service_account_scope_denied",
"required_permission": "tenant.acme.crm.tasks.update"
}
The implementation lives in App\Http\Middleware\CheckPermission::scopeAllowsPermission
segmentsMatch.
Provisioning flow
Identity-side (main-identity-backend):
- Admin creates the API key for a service-account user with a scope list.
- Identity stores the key + the canonical
scopes[]array. - Identity rejects scopes that don't match its
PermissionRegistry(post F-future-8) — so a typo (tenant.crm.tasks.viw) fails at provision time, not at the first call.
CRM-side: nothing to do at provision time. The scope claim arrives on the token at first use.
Drift detection (cross-repo #38)
CRM enforces scopes, identity issues them, and the two must agree on the permission namespace. To catch drift before prod:
# In tenant-backend-crm/:
php artisan crm:list-permissions --fqn --json > /tmp/crm-perms.json
# Compare against identity's PolicySeeder (a CI script can diff
# /tmp/crm-perms.json against the wildcards in
# main-identity-backend/.../PolicySeeder.php).
CI is the right place for this — manual diffs miss adds. The
api:check step in tenant-frontend/package.json already gates the
generated SDK against staleness for the same reason.
Operational notes
- Token rotation: identity-side is the rotation surface. CRM caches
the policy fetch under
policies:{tid}:{uid}:{sha256(token)[:12]}so the new token lands on a fresh slot and old slots TTL-clear (cross-repo #39). - Revocation: identity revokes; CRM picks it up within ~60s (user
cache TTL). For higher-confidence revocation, flush the policy tag
too:
Cache::tags(['crm.policies', "tenant:{$tenantId}"])->flush(). - Auditing service-account actions: the
auth_user['id']value for a service account is the API-key owner's user id. Combined with theprovisioned_byfield onSmsAccountand theactor_user_id/impersonator_idinActivityLog::properties, every action has a human trail. - Rate limits: bot tokens (a special class of service account) hit
VerifyBotToken's per-(IP, token-prefix) rate limiter (30 failures/ minute) — this is CRM-side. Regular API-key tokens rely on identity's upstream rate limiter for now.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
403 service_account_scope_denied on a clearly-needed action | Scope list missing the verb | re-issue token with the missing scope (identity admin) |
Wildcard tenant.*.crm.tasks.* doesn't match a 6-segment permission | Trailing * covers deeper segments — should match; check segment count | bug report w/ exact permission string |
| Two service accounts share a scope but only one works | Token expired; the other's still valid | re-issue both |
policies: cache key suffix changed → service account behaves stale | Token rotation happened — wait the TTL or flush tag | tag flush per the note above |
Where to learn more
- security.md — full 5-layer model
- api.md — per-endpoint permission list
documentations/docs/identity/service-accounts.md— the issuer side