API Contract: Service Accounts (BFF)
All routes under ui/src/app/api/admin/service-accounts/. All require an authenticated session.
All responses use the repo's { success, data?, error? } envelope. Authorization is enforced
per-route (team membership / service_account#can_manage) regardless of UI state.
Common errors: 401 (unauthenticated), 403 (not a member of the relevant team / lacks
can_manage), 404 (SA not found or not visible to caller), 409 (name conflict), 503 (Mongo or
Keycloak/OpenFGA unavailable).
GET /api/admin/service-accounts
List service accounts the caller can manage (active SAs in teams the caller belongs to). (FR-014, FR-021)
Response 200
{ "success": true, "data": { "items": [
{ "id": "<sa_sub>", "name": "incident-bot", "description": "PagerDuty",
"owning_team_id": "team-sre", "created_by": "<uuid>", "created_at": "…",
"status": "active", "scope_counts": { "agents": 2, "tools": 5 } }
] } }
Never returns credential material. Revoked SAs excluded by default (?include_revoked=true optional).
POST /api/admin/service-accounts
Create a service account. (US1; FR-001..FR-008)
Request
{ "name": "incident-bot", "description": "PagerDuty integration",
"owning_team_id": "team-sre",
"scopes": [ { "type": "agent", "ref": "incident-resolver" },
{ "type": "tool", "ref": "jira/search" } ] }
Server flow
check(user:<caller>, member, team:<owning_team_id>)→ else403. (FR-002)- Name unique among active SAs in team → else
409. (FR-002a) - For each requested scope:
check(user:<caller>, <rel>, <object>); drop/reject any not held. Reject the whole request if any requested scope is unauthorized (return which). (FR-006/008) - Keycloak: create confidential client → read back
client_uuid,client_secret, and service-account-usersub. (WS-B) - OpenFGA: write
owner_teamtuple + one tuple per granted scope. (data-model) - Mongo: insert
service_accountsdoc (statusactive,scopes_snapshot). - Audit:
service_account.create. (FR-026)
Response 201 — the ONLY time the secret is returned. (FR-005)
{ "success": true, "data": {
"id": "<sa_sub>", "name": "incident-bot", "owning_team_id": "team-sre",
"credential": { "client_id": "caipe-sa-incident-bot-a1b2c3", "client_secret": "<shown once>",
"token_url": "https://…/realms/caipe/protocol/openid-connect/token" },
"granted_scopes": [ … ], "rejected_scopes": [] } }
GET /api/admin/service-accounts/:id
Detail + current scopes (read from OpenFGA, not the snapshot). (FR-014)
403 unless check(user:<caller>, can_manage, service_account:<id>).
{ "success": true, "data": {
"id": "<sa_sub>", "name": "incident-bot", "description": "…", "owning_team_id": "team-sre",
"created_by": "<uuid>", "created_at": "…", "status": "active",
"scopes": [ { "type": "agent", "ref": "incident-resolver" },
{ "type": "tool", "ref": "jira/search" } ] } }
POST /api/admin/service-accounts/:id/scopes
Add a scope. (US3; FR-015)
Request: { "type": "agent" | "tool", "ref": "<id or server/tool>" }
Flow: check(can_manage) → then check(user:<editor>, <rel>, <object>) (editor must hold it,
else 403) → write tuple → update snapshot → audit service_account.scope_add. (FR-015/026)
DELETE /api/admin/service-accounts/:id/scopes
Remove a scope. (US3; FR-016)
Request: { "type": "agent" | "tool", "ref": "<…>" }
Flow: check(can_manage) ONLY (no requirement that the editor holds the scope) → delete tuple →
update snapshot → audit service_account.scope_remove. (FR-016/026)
POST /api/admin/service-accounts/:id/rotate
Rotate the credential. (US4; FR-017/019)
Flow: check(can_manage) → Keycloak POST /clients/{client_uuid}/client-secret → audit
service_account.rotate. Scopes unchanged.
Response 200 — new secret shown ONCE:
{ "success": true, "data": { "credential": {
"client_id": "caipe-sa-incident-bot-a1b2c3", "client_secret": "<new — shown once>",
"token_url": "…" } } }
DELETE /api/admin/service-accounts/:id
Revoke (terminal). (US4; FR-018/018a)
Flow: check(can_manage) → delete Keycloak client → delete ALL OpenFGA tuples for
service_account:<id> (ownership + scopes) → mark Mongo status: "revoked", revoked_at (retain
doc for audit) → audit service_account.revoke. Name freed for reuse in the team. (FR-018a)
Response 200: { "success": true, "data": { "id": "<sa_sub>", "status": "revoked" } }
GET /api/admin/service-accounts/grantable
List the agents and tools the calling user currently holds, to populate the create/add-scope picker. (FR-009)
?team_id= optional (does not affect the grantable set — that's the user's own perms, FR-007 — but
may be used to pre-select the owning team).
{ "success": true, "data": {
"agents": [ { "ref": "incident-resolver", "name": "Incident Resolver" } ],
"tools": [ { "ref": "jira/search", "name": "Jira: search" },
{ "ref": "jira/*", "name": "Jira: all tools" } ] } }
Backed by listOpenFgaObjects(user:<caller>, can_use, agent) and the tool equivalent. (R-8)
Authentication for the resulting service account (out-of-band, not a BFF route)
The SA authenticates to CAIPE like any caller — it is NOT a special endpoint:
POST {token_url} # Keycloak
grant_type=client_credentials&client_id=…&client_secret=…
→ { access_token: <JWT, sub=sa_sub, preferred_username=service-account-…, client_id=…> }
POST {CAIPE_API_URL}/api/v1/chat/invoke # BFF (same route browsers/Slack use)
Authorization: Bearer <JWT>
→ BFF validates (isServiceAccount=true) → forwards JWT to DA
→ DA: check service_account:<sub> can_use agent:<id> (WS-G fix)
→ Gateway bridge per tool: agent can_call tool AND service_account:<sub> can_call tool (WS-F)
(FR-010/011/012/013)