Phase 0 Research: Service Accounts
All items below were verified against the current codebase (not assumed). Each is a decision with rationale and the evidence that grounds it.
R-1 β OpenFGA identity & ownership modelβ
Decision: Reuse the existing service_account OpenFGA type as the SA identity. Add an ownership
relation:
type service_account
relations
define owner_team: [team#member]
define can_manage: owner_team
SA access grants reuse existing patterns: service_account:<sub> can_use agent:<id> and
service_account:<sub> can_call tool:<server>/<tool> (+ tool:<server>/* wildcard).
Rationale: Slack/Webex bots are already modeled as service_account subjects; these new
user-minted accounts are the same kind of identity with self-service management. service_account
is subject-only today, so adding relations is purely additive.
Evidence: deploy/openfga/model.fga:29 (type service_account, no relations). tool type
(model.fga:199-211) β caller relation already lists both user and service_account, so
caller-keyed tool grants/checks need no model change. Subject convention confirmed in
ui/src/lib/rbac/resource-authz.ts:147-155.
Alternatives rejected: New bot_account type β more model churn and a second machine-identity
concept for no functional gain (Kevin/Erik decision).
R-2 β Credential: dynamic Keycloak client per SAβ
Decision: Each SA is a dynamically-created Keycloak confidential client with
serviceAccountsEnabled: true. One client = one credential. The credential shown once = the Keycloak
client_id + client_secret. Rotation = regenerate the client secret. Revoke = delete the client.
No multi-token, no expiry in v1.
Rationale: "Work with Keycloak as it's meant to be." The SA is a first-class identity with its own real JWT (client-credentials grant) β identical in kind to how Slack/Webex authenticate, just created at runtime instead of statically in realm JSON.
Evidence (capability verified):
- Admin client
caipe-platformalready holdsrealm-managementrolemanage-clientsβcharts/ai-platform-engineering/charts/keycloak/realm-config.json:719-735. Sufficient to create/regenerate/delete clients. - Existing admin plumbing to mirror:
ui/src/lib/rbac/keycloak-admin.tsβadminFetch(l.215),assertOk(l.241),getAdminToken(l.192, client_credentials),getClientByClientId(l.845), and an existingGET /clients/{id}/service-account-usercall (l.1324-1332). - Client shape to mirror:
caipe-slack-botβrealm-config.json:164-187(publicClient:false, serviceAccountsEnabled:true, standardFlowEnabled:false, directAccessGrantsEnabled:false).
Net-new code (none exists today): createServiceAccountClient, regenerateClientSecret,
deleteServiceAccountClient in keycloak-admin.ts. Keycloak REST: POST /clients,
POST /clients/{id}/client-secret, DELETE /clients/{id}, GET /clients/{id}/service-account-user.
Alternatives rejected: opaque token + Mongo hash (catalog-api-keys style) β wouldn't give a real SA JWT for downstream MCP checks. BFF-signed JWT β second issuer to trust. Token-as-Keycloak-secret with N tokens β fights Keycloak's one-secret-per-client model and fragments identity.
R-3 β Subject identity = JWT sub (no mapping table)β
Decision: The OpenFGA subject id IS the Keycloak service-account-user sub (UUID). At create
time we read it back from GET /clients/{id}/service-account-user and write all tuples as
service_account:<sub>. Mongo stores the friendly name + client_id + sub for display.
Rationale: Zero translation layer; reuses the existing service_account:${sub} convention so the
same value works at every enforcement layer.
Evidence: BFF detection + namespacing already exists β
ui/src/lib/jwt-validation.ts:243-249 sets isServiceAccount from
preferred_username.startsWith('service-account-'); api-middleware.ts:476-493 propagates it into
the session; resource-authz.ts:147-155 yields service_account:${sub}. So BFF-layer authz
already works for SAs with no change.
FINALIZED (T002) β the single canonical service-account detection ruleβ
Rule: a token is a service account iff its
preferred_usernameclaim starts withservice-account-. All three enforcement layers MUST use exactly this rule so a given token namespaces identically everywhere:
Layer File Status BFF ui/src/lib/jwt-validation.tsβ already implements it (no change) DA backend (WS-G / T018) dynamic_agents/.../auth/openfga_authz.pymust adopt it AGW bridge (WS-F / T020) deploy/openfga/bridge/main.pymust adopt it Subject id when SA β
service_account:<sub>; otherwiseuser:<sub>(<sub>= JWTsub).Why
preferred_username, notclient_id/azp(this supersedes the R-6 provisional lean towardclient_id):
- Zero BFF churn / already proven. The BFF path uses it today and works; matching it guarantees the three layers agree rather than introducing a second signal that could disagree on an edge token.
- Guaranteed by Keycloak. A confidential client with
serviceAccountsEnabledalways issues its client-credentials tokens withpreferred_username = service-account-<clientId>. Human users never have apreferred_usernamein that namespace.azp/client_idis less reliable as the primary signal β the bearer claim isazpin standard Keycloak access tokens (a literalclient_idclaim depends on a protocol-mapper), andazpis also present on interactive-user tokens (it names the client the user logged in through), so it does not by itself distinguish a service account from a user.Corroboration only: layers MAY additionally read
azp/client_idfor logging, but the allow/deny namespacing decision keys onpreferred_usernamealone.Helper: both Python layers should share one tiny predicate, e.g.
is_service_account(payload) -> bool: return str(payload.get("preferred_username", "")).startswith("service-account-"), and build the subject asf"service_account:{sub}"vsf"user:{sub}". Bothopenfga_authz.py(T018) andbridge/main.py(T020) already decode the full validated JWT payload, sopreferred_usernameis available with no extra token work.
R-4 β Call path: all traffic via BFF; SA JWT forwarded downstreamβ
Decision: External callers present the SA's client-credentials JWT to the BFF /api/v1/chat/*
routes (same entry as browser + Slack/Webex). The BFF forwards the original JWT to the DA backend.
Evidence: ui/src/lib/da-proxy.ts β authenticateRequest reads session.accessToken (the
original bearer, l.106) and buildBackendHeaders forwards it as Authorization: Bearer (l.206-208).
DA backend is ClusterIP-only (verified earlier), so BFF is the only ingress. No opaque-token path
needed.
R-5 β DA backend subject namespacing (BUG β must fix, WS-G)β
Decision: Fix _check_agent_use to namespace SA subjects.
Problem found: ai_platform_engineering/dynamic_agents/.../auth/openfga_authz.py:304-321
hardcodes "user": f"user:{subject}". An SA's forwarded JWT would be checked as
user:<sub> can_use agent:<id> and denied, because SA grants are service_account:<sub> ....
The DA backend has no isServiceAccount detection today (the flag lives only in the BFF session
and never crosses to DA).
Fix: In the DA backend, detect service-account tokens from the validated JWT (via
preferred_username starting service-account-, or the client_id claim) and build
service_account:<sub> for those; keep user:<sub> for interactive users. Add pytest coverage.
R-6 β Caller-keyed tool authorization (in-scope fix, WS-F)β
Decision: Add a caller-keyed <subject> can_call tool:<server>/<tool> check to the bridge,
ANDed with the existing agent:<id> can_call tool:... check. Applies to both user and
service_account subjects (FR-012a/b).
Evidence: deploy/openfga/bridge/main.py:595 builds user = f"user:{sub}" (hardcoded);
l.618-630 check userβagent then agentβtool β no callerβtool check exists. The bridge decodes
the JWT (_decode_verified_bearer_subject, l.321-345) but keeps only sub; it can additionally read
client_id to namespace the subject. Model already allows both subjects on tool#caller (R-1) β no
model change. Tests to mirror: test_tools_call_requires_user_agent_and_agent_tool_grants and
test_tools_call_denies_when_agent_tool_grant_is_missing (test_grpc_bridge.py:247-321). New deny
reason code DENY_CALLER_TOOL following the existing _audit_decision pattern.
Consistency note: BFF uses preferred_username, the bridge will use client_id. WS-F and WS-G
must apply the same service-account-detection rule so a given token namespaces identically at
every layer. Recommendation: prefer client_id presence as the canonical signal (present on all
client-credentials tokens), with preferred_username as corroboration; finalize in tasks.
COARSE-GATE FIX (discovered during T020/T028 β mcp_gateway.caller)β
Once the bridge namespaces SA subjects as service_account:<sub>, the coarse
ext_authz gate β _check_openfga(<subject>, can_call, mcp_gateway:list), run on EVERY request before
the tool-specific checks β applies to SAs too. Unlike tool#caller, the mcp_gateway.caller relation
was [user] ONLY, so:
- an SA would always fail the coarse gate (no baseline), and
- the baseline tuple could not even be written β OpenFGA rejects
service_account:<sub> caller mcp_gateway:listwithtype 'service_account' is not an allowed type restriction for 'mcp_gateway#caller'.
Fix (additive, applied): add service_account to mcp_gateway.caller in deploy/openfga/model.fga
and charts/.../authorization-model.json (mirrors how humans hold the baseline caller mcp_gateway:list
via baseline-access.ts). The SA equivalent is written at create time and removed on revoke
(ui/src/app/api/admin/service-accounts/route.ts + [id]/route.ts). Humans get the baseline from the
login/bootstrap reconciler; SAs never log in, so the create route writes it explicitly. Without this,
quickstart S1 (create) fails at the OpenFGA write and S4/S5 (external call) fail the coarse gate.
R-7 β UI placement & gating (RESOLVED β T001)β
Finding: The admin page (ui/src/app/(app)/admin/page.tsx) uses a two-level Radix tab system;
the "Settings" category is the right home for a "Service Accounts" sub-tab. Tabs are gated via
tabGateValues and the page is fronted by useAdminRole() (canViewAdmin/isAdmin).
Tension: Spec says self-service for any team member (not admin-only). So the tab's visibility
must key on "user belongs to β₯1 team," not isAdmin.
Decision (FINAL, T001): Mount a service-accounts sub-tab under the Settings category in
ui/src/app/(app)/admin/page.tsx CATEGORIES. Gate it on team membership (user belongs to β₯1
team), NOT isAdmin. Real control is per-action owning-team authorization on every BFF route
(can_manage / membership checks), exactly as the rest of the feature already enforces.
Verified during T001 (no longer "provisional"):
- Non-admins already reach the admin page.
admin/page.tsxis wrapped only in<AuthGuard>(authentication, not admin role) βpage.tsx:3228-3232.useAdminRoledocstring + behaviour confirm: "All authenticated users can view the Admin dashboard (read-only)."isAdminonly gates write affordances (Create Team button, role edits, simulation "View as",ai_review). So no page-level gating change is needed β a non-admin team member can already land here. - Tab visibility is data-driven via
tabGateValues[gateKey](page.tsx:496-523), fed bygatesfromGET /api/rbac/admin-tab-gates. A category/tab renders iff itsgateKeyistrue. - A non-admin, resource-scoped gate precedent already exists.
hasResourceScopedIntegrationAccess(admin-tab-gates/route.ts) turns the Slack/Webex tabstruefor non-admins who can_manage β₯1 channel/space β i.e. visibility keyed on resource relationship, not org-admin. Service Accounts follows the identical pattern: visible to anyone who belongs to β₯1 team.
Gate mechanism (for T014 to implement):
- Add gate key
service_accountstoAdminTabKey/AdminTabGatesMap(ui/src/lib/rbac/types.ts), toEMPTY_GATES+ the dev-authallAdminTabGatesset (useAdminTabGates.ts), and toALL_TABSinadmin-tab-gates/route.ts. - In
admin-tab-gates/route.ts, computeservice_accounts = (member of β₯1 team)for the current subject β derive vialistOpenFgaObjects(user:<sub>, member, team)(length β₯ 1) using the sameui/src/lib/rbac/openfga.tshelper the grantable/TeamPicker paths use. Fail-closed on error. This is a non-admin, resource-scoped gate (mirrorhasResourceScopedIntegrationAccess), NOT an org-adminhasAdminSurfaceManagecheck. - Register the tab in
CATEGORIESunderkey: 'settings':{ value: 'service-accounts', label: 'Service Accounts', icon: Bot, gateKey: 'service_accounts' }, add'service-accounts'toVALID_TABS, and render its<TabsContent>guarded bytabGateValues.service_accounts(noisAdminconditioning).
Rejected: gating on isAdmin (contradicts FR self-service-for-any-team-member); a separate
non-admin settings surface outside /admin (unnecessary β the admin page is already non-admin
reachable, and Settings is the documented home for identity settings).
Reusable components verified: Tabs, Dialog, CopyButton, TeamPicker
(ui/src/components/ui/team-picker.tsx), SecretValueDialog
(ui/src/components/credentials/), and the fetch/{success,data,error} pattern
(PlatformSettingsTab.tsx). Grantable resources: /api/dynamic-agents/available (agents) and the
team-resources endpoint expose available.tools; the SA grantable list (WS-D) will use OpenFGA
listOpenFgaObjects for user:<sub> to be authoritative.
R-8 β Grantable-set derivation (FR-006/007/009)β
Decision: UI picker = listOpenFgaObjects(user:<sub>, can_use, agent) and the tool equivalent,
so only what the user holds is offered. Every write re-runs checkOpenFgaTuple(user:<sub>, β¦)
(FR-008). ui/src/lib/rbac/openfga.ts provides listOpenFgaObjects, checkOpenFgaTuple,
writeOpenFgaTuples β all reused, none net-new.
R-9 β Coarse mcp_gateway:list gate baseline for service accounts (BUG β must fix)β
Decision: Service accounts need an EXPLICIT service_account:<sub> caller mcp_gateway:list
tuple, written at create and removed at revoke β AND the OpenFGA model must allow
service_account on mcp_gateway#caller (currently [user] only).
Problem found (investigation, task #28):
- The bridge's ext_authz entry point runs a COARSE gate for EVERY caller before any per-tool
check:
deploy/openfga/bridge/main.py:648β_check_openfga(user, "can_call", "mcp_gateway:list"). If this returns false, the request is denied (DENY_NO_CAPABILITY) and the per-tool / caller-keyed checks at l.650+ never run. This applies toservice_account:<sub>subjects too β there is no SA exemption (BYPASS_SUBSis an explicit env allowlist, not a class). - Humans get this baseline via a per-USER tuple, not team inheritance:
memberBaselineGrantDefinitions()(ui/src/lib/rbac/baseline-access.ts:136-140) definesuser:<sub> caller mcp_gateway:list, written byrepairCurrentUserBaselineon every admin-page load /baselineBootstrapTuples. Service accounts NEVER log into the BFF, and theirowner_teamcarries nomcp_gatewaygrant β so they have no path to this tuple. - The model also blocks it:
mcp_gateway#calleris[user]only (model.fga:203-206;authorization-model.json:1976-1982). Even an explicitservice_account:<sub>tuple would fail to write/resolve untilservice_accountis added to that relation.
Net effect without the fix: every SA tool call (quickstart S4/S5) is denied at the coarse gate
even with perfectly correct agent/tool scopes. Silent β the deny reason is the generic
DENY_NO_CAPABILITY, not a scope error.
Fix (split across two owners):
- Model (WS-A / implementer-a):
mcp_gateway.caller: [user, service_account]indeploy/openfga/model.fga, recompiled intoauthorization-model.json+ re-seeded. - BFF (WS-D / implementer-b, DONE): create route writes
service_account:<sub> caller mcp_gateway:listalongside owner_team + scopes; revoke route deletes it with the rest. Create + rotate-revoke Jest tests assert the tuple is written/removed.ui/src/app/api/admin/service-accounts/route.ts.../[id]/route.ts.
Verify: quickstart S4 on the live stack β an SA with a granted agent+tool should now pass the coarse gate and the per-tool check. Coordinated with testing-manager.
Resolved risks summaryβ
| Risk (from working notes) | Status |
|---|---|
| Keycloak admin can create clients? | β
Yes β caipe-platform has manage-clients |
| SA JWT validates + namespaces at BFF? | β
Yes β existing isServiceAccount path |
| DA agent-use check works for SA? | β No β hardcoded user: β WS-G fix required |
.fgaβJSON model change for caller fix? | β
Not needed β tool#caller already allows both |
| Caller-keyed tool check exists? | β No β WS-F adds it |
| UI self-service vs admin-gated? | β οΈ Open β R-7, resolve in tasks |
SA passes coarse mcp_gateway:list gate? | β No β R-9: model change (mcp_gateway#caller += service_account) + create/revoke baseline tuple required |