Skip to main content

Service Accounts

Service accounts are non-human principals — typically a CI worker, a warehouse robot, or an integration service-to-service caller. Each service account is a users row with is_service_account = true plus one or more API keys (Passport personal-access tokens with optional scopes and an enforced TTL).

Lifecycle

ServiceAccountController.store
↓ (creates users row + tenant_user pivot)
service account user

ServiceAccountController.createApiKey ← F7: enforces TTL + scopes
↓ (Passport createToken with $appliedScopes)
oauth_access_tokens row

is_service_account is mass-assignable on users; it's only is_system_admin that requires the explicit setSystemAdmin() setter (see Security).

API key issuance (F7)

POST /api/tenants/{tenantId}/service-accounts/{userId}/api-keys
Authorization: Bearer {admin-token}
Content-Type: application/json

{
"name": "Warehouse robot 2",
"expires_at": "2026-12-31T23:59:59Z", // optional
"scopes": ["warehouse.inventory.read"] // optional
}

Response:

{
"success": true,
"data": {
"key": "Hk7X2…", // PLAINTEXT — shown ONCE, never recoverable
"token_id": "01h…",
"name": "Warehouse robot 2",
"expires_at": "2026-12-31T23:59:59+00:00",
"scopes": ["warehouse.inventory.read"]
}
}

Default and maximum TTL

The audit (#144) flagged that service-account keys could be issued without expiry, giving the worst per-key blast radius across the platform. F7 fixes it via two config knobs:

ConfigDefaultBehaviour
auth.service_account_default_ttl_days90Used when the request omits expires_at.
auth.service_account_max_ttl_days365Hard cap — anything beyond this is clamped to now() + 365 days.

Set these via env (AUTH_SERVICE_ACCOUNT_DEFAULT_TTL_DAYS / AUTH_SERVICE_ACCOUNT_MAX_TTL_DAYS).

Scopes

scopes is recorded on the issued token via Passport's createToken($name, array $scopes). Tenant-services pick them up from the /api/user payload (which MeController::userSimplified now includes alongside tenants[]) and enforce them in CheckPermission:

$tokenScopes = $user['scopes'] ?? [];
if (!empty($tokenScopes) && !$this->scopeAllowsPermission($tokenScopes, $fullAction)) {
return response()->json([
'error' => 'service_account_scope_denied',
'required_permission' => $fullAction,
], 403);
}

A service-account token may carry exact permissions (identity.users.list), prefix wildcards (tenant.acme.crm.*), or the universal *. The match is segment-by-segment: tenant.*.crm.tasks.* covers tenant.acme.crm.tasks.view but not tenant.acme.crm.tasks.view.foo. Human-user tokens carry no scopes; for them the gate is a no-op and the regular policy chain is the only check.

Audit cross-repo #74 / #143 / #144 — closed by validating both ends:

  1. IssuanceApiKeyService::syncServiceAccountPermissions calls PermissionRegistry::findInvalid() and throws InvalidArgumentException on any unknown permission string. A typo like idntity.users.delete is now refused at creation time, so it can't land on the service-account's attached policy.
  2. Request time — the CheckPermission middleware gates every request against the token's scopes[], not just the policy chain. Even if a stale policy accidentally allows more than the scope, the token can't exercise it.

PermissionRegistry::isValidScope($scope) is the canonical registry-aware validator; it recognises both concrete keys (via the existing exists() regex) and wildcard scopes (identity.*, *, tenant.*.crm.*) by probing the registry with a substituted placeholder segment.

Reference port: tenant-backend-crm/app/Http/Middleware/CheckPermission.php — the other six tenant services follow the same pattern by copying the scopeAllowsPermission() + segmentsMatch() helpers.

Rotation

POST /api/tenants/{tenantId}/service-accounts/{userId}/api-keys/{keyId}/rotate

Revokes the named token and issues a new one with the same name / scopes / expires_at. The frontend ApiKeysTab UI shows the new plaintext immediately and warns it's only shown once.

Revocation

DELETE /api/tenants/{tenantId}/service-accounts/{userId}/api-keys/{keyId}

Sets oauth_access_tokens.revoked = true. The associated refresh token is revoked too. Subsequent calls present a revoked token and get 401 unauthenticated.

Listing

GET /api/tenants/{tenantId}/service-accounts/{userId}/api-keys

Returns name, id, expiry, last-used (best-effort from oauth_access_tokens.last_used_at if the schema has it), and scopes. Plaintext is never returned for an existing key — only at creation or rotation.

Backend permission gates

EndpointPermission
service-accounts.indexidentity.service_accounts.list
service-accounts.storeidentity.service_accounts.create
service-accounts.showidentity.service_accounts.view
service-accounts.updateidentity.service_accounts.update
service-accounts.destroyidentity.service_accounts.delete
api-keys.listidentity.api_keys.list
api-keys.createidentity.api_keys.create
api-keys.revokeidentity.api_keys.revoke

ServiceAccountPolicy exposes Gate-bound checks (identity.service_account.api_keys.list, ...api_keys.create, ...api_keys.revoke) for resource-scoped authorisation in F-future.

Defense in depth

Audit #143 flagged that ServiceAccountController::authorizeUserForTenant only checked tenant membership before granting access to the API-key mutation endpoints. The route-level permission:identity.service_accounts.create middleware is the primary gate; the controller-level membership check is the second layer. If the route-level permission ever drifts, the controller still refuses callers who aren't members of the target tenant.

Where to look in code

  • app/Http/Controllers/ServiceAccountController.php — endpoints
  • app/Services/ApiKeyService.php — Passport token plumbing + scope recording
  • app/Policies/ServiceAccountPolicy.php — Gate-bound checks
  • tests/Feature/ServiceAccountControllerTest.php (F8 backlog) — full TTL + scope coverage planned