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:
| Config | Default | Behaviour |
|---|---|---|
auth.service_account_default_ttl_days | 90 | Used when the request omits expires_at. |
auth.service_account_max_ttl_days | 365 | Hard 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:
- Issuance —
ApiKeyService::syncServiceAccountPermissionscallsPermissionRegistry::findInvalid()and throwsInvalidArgumentExceptionon any unknown permission string. A typo likeidntity.users.deleteis now refused at creation time, so it can't land on the service-account's attached policy. - Request time — the
CheckPermissionmiddleware gates every request against the token'sscopes[], 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
| Endpoint | Permission |
|---|---|
service-accounts.index | identity.service_accounts.list |
service-accounts.store | identity.service_accounts.create |
service-accounts.show | identity.service_accounts.view |
service-accounts.update | identity.service_accounts.update |
service-accounts.destroy | identity.service_accounts.delete |
api-keys.list | identity.api_keys.list |
api-keys.create | identity.api_keys.create |
api-keys.revoke | identity.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— endpointsapp/Services/ApiKeyService.php— Passport token plumbing + scope recordingapp/Policies/ServiceAccountPolicy.php— Gate-bound checkstests/Feature/ServiceAccountControllerTest.php(F8 backlog) — full TTL + scope coverage planned