Skip to main content

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_user payload has no human fields (no email, no display name) and carries the API key's owning user as provisioned_by for 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:

ScopeMatches
tenant.acme.crm.tasks.viewexactly this permission
tenant.acme.crm.tasks.*every action on tasks in acme
tenant.*.crm.tasks.viewview 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):

  1. Admin creates the API key for a service-account user with a scope list.
  2. Identity stores the key + the canonical scopes[] array.
  3. 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 the provisioned_by field on SmsAccount and the actor_user_id / impersonator_id in ActivityLog::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

SymptomCauseFix
403 service_account_scope_denied on a clearly-needed actionScope list missing the verbre-issue token with the missing scope (identity admin)
Wildcard tenant.*.crm.tasks.* doesn't match a 6-segment permissionTrailing * covers deeper segments — should match; check segment countbug report w/ exact permission string
Two service accounts share a scope but only one worksToken expired; the other's still validre-issue both
policies: cache key suffix changed → service account behaves staleToken rotation happened — wait the TTL or flush tagtag 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