Skip to main content

Phase 1 Data Model: Service Accounts

Three stores of record, each authoritative for a different concern:

StoreOwnsSource of truth for
Keycloakthe confidential client + its secretthe credential (identity)
OpenFGAtuples on service_account:<sub>access (ownership + scopes)
MongoDBservice_accounts documentdisplay metadata (name, description, status, links)

The Mongo doc is a convenience/index layer. Access decisions never read it; they read OpenFGA. The credential is never stored anywhere in CAIPE β€” Keycloak holds it and shows it once.


Entity: Service Account​

MongoDB document β€” collection service_accounts​

// ui/src/types/mongodb.ts
export interface ServiceAccount {
_id?: ObjectId;
sa_sub: string; // Keycloak service-account-user UUID β€” the OpenFGA subject id. UNIQUE.
client_id: string; // Keycloak clientId, e.g. "caipe-sa-incident-bot-a1b2c3". UNIQUE.
client_uuid: string; // Keycloak internal client UUID (for admin API calls: secret/delete).
name: string; // Human-friendly name, unique among ACTIVE SAs within owning_team_id.
description?: string;
owning_team_id: string; // The single owning team (team slug/id used in OpenFGA team:<id>).
created_by: string; // Keycloak sub of the creating user (audit/display).
created_at: Date;
status: "active" | "revoked";
revoked_at?: Date | null;
// Display cache ONLY β€” not authoritative. OpenFGA tuples are the source of truth for access.
scopes_snapshot?: ServiceAccountScope[];
}

export interface ServiceAccountScope {
type: "agent" | "tool";
// For agent: the agent id. For tool: "<server>/<toolname>" or "<server>/*".
ref: string;
added_by: string; // Keycloak sub of who added this scope (audit).
added_at: Date;
}

No credential material is persisted β€” no secret, no hash. (Contrast with catalog_api_keys, which stores a hash; here Keycloak owns the secret entirely.)

Indexes (created in ui/src/lib/mongodb.ts createIndexes())​

IndexTypeWhy
{ sa_sub: 1 }uniqueprimary lookup by OpenFGA subject; one SA per Keycloak SA user
{ client_id: 1 }uniqueuniqueness of Keycloak client; lookup
{ owning_team_id: 1, status: 1 }compoundlist active SAs for a team (the main list query)
{ owning_team_id: 1, name: 1, status: 1 }compoundenforce name-unique-among-active-in-team (FR-002a)
{ created_by: 1 }β€”audit / "created by me" filters

Name uniqueness (FR-002a) is enforced at the application layer on create (query active SAs in the team for the name), not via a partial unique index, to keep the "freed on revoke" semantics (FR-018a) simple and explicit. Documented as a deliberate choice.

State transitions​

(none) ──create──▢ active ──rotate──▢ active        (credential changes; status unchanged)
β”‚
β”œβ”€β”€add-scope / remove-scope──▢ active (OpenFGA tuples change; doc.scopes_snapshot updated)
β”‚
└──revoke──▢ revoked (TERMINAL β€” FR-018a; Keycloak client deleted, tuples removed)
  • revoked is terminal and irreversible. The doc is retained for audit; excluded from the active list; its name becomes reusable within the team (uniqueness is among active only).

OpenFGA tuples (authoritative for access)​

Written/removed by the BFF routes. <sub> = sa_sub; <team> = owning_team_id.

PurposeTupleWritten whenRemoved when
Ownershipteam:<team>#member β†’ owner_team β†’ service_account:<sub>createrevoke
Coarse-gate baselineservice_account:<sub> β†’ caller β†’ mcp_gateway:listcreaterevoke
Agent grantservice_account:<sub> β†’ can_use β†’ agent:<id>create / add-scoperemove-scope / revoke
Tool grantservice_account:<sub> β†’ can_call β†’ tool:<server>/<tool> (or …/*)create / add-scoperemove-scope / revoke

Coarse-gate baseline (R-9 / #28): the AgentGateway ext_authz bridge runs a coarse <subject> can_call mcp_gateway:list check on EVERY MCP request before the per-tool checks. Humans get this baseline at login bootstrap (baseline-access.ts); SAs never log in, so the create route writes service_account:<sub> caller mcp_gateway:list explicitly (and revoke deletes it). Without it an SA fails the coarse gate even with valid agent/tool grants. Requires the model change below (mcp_gateway.caller now also accepts service_account).

Management authority derives from ownership: service_account:<sub>#can_manage = owner_team (= any team:<team>#member). BFF authorizes manage actions with check(user:<editor>, can_manage, service_account:<sub>).

Model changes (deploy/openfga/model.fga, mirrored into authorization-model.json)​

type service_account
relations
define owner_team: [team#member]
define can_manage: owner_team

type mcp_gateway
relations
define caller: [user, service_account] # was [user] β€” added service_account (R-9/#29)
define can_call: caller

(service_account was subject-only; the owner_team/can_manage relations are additive. tool#caller already permits user and service_account β€” no change needed there. mcp_gateway.caller needed service_account added so the coarse-gate baseline tuple above is writable.)


Validation rules (from spec FRs)​

  • FR-002: owning_team_id MUST be a team the creating user belongs to (check(user, member, team)).
  • FR-002a: name unique among active SAs within owning_team_id, compared case-insensitively (FR-002a). T007 normalizes (lowercases) the name for the uniqueness check; the original-cased name is stored for display.
  • FR-004: a new SA has zero scope tuples until grants are written.
  • FR-006/008/015: every agent/tool grant write is preceded by check(user:<editor>, <relation>, <object>); reject on false.
  • FR-016: scope removal requires only check(user:<editor>, can_manage, service_account:<sub>) (owning-team membership) β€” NOT holding the scope.
  • FR-020: scopes are static; no process re-derives them from the creator over time.
  • FR-025: team deletion blocked while any service_account:<sub> owner_team team:<team> exists.

Entity relationships​

team (1) ──owns──▢ (N) service_account ──grants──▢ (N) agent
└─grants──▢ (N) tool
service_account (1) ◀──identity── (1) keycloak client ──has──▢ (1) client_secret [shown once]
user (creator) ──created──▢ service_account (audit only; no ongoing authority)
user (owning-team member) ──can_manage──▢ service_account (via team membership)