Identity is the platform checkpoint: accounts, keys, and the rule that you only touch what you own. Every
other domain assumes a request already passed through it.
On every request it resolves three things:
- Who is asking — resolved once at the edge, never from the client’s word.
- What they touch, and who owns it — every meeting, recording, and workspace has an owner.
- Whether they are allowed — a default-deny decision.
It also mints short-lived, single-purpose tokens so an agent acts on your behalf without holding your
API key (see zero-trust).
Two forms, on purpose:
admin-api (core/identity/services/admin-api) — the live, database-backed service: user accounts,
API tokens, and the /internal/validate oracle the gateway calls.
identity_core (core/identity/src/identity_core) — a pure, dependency-free library of the rules
(mint a token, check access, mint a dispatch token, broker a secret), sealed as the
identity.v1 contract so every service decides the same way.
This page is the identity
domain — accounts, the access model, what’s built, and the roadmap.
The cross-cutting
trust flow — how a dispatch’s authorization is proven and verified at every hop —
is its own document:
Identity & trust.
The model
Six rules — the reasoning, the mechanism, and the established practice each comes from.
| Rule | Why | Mechanism | Maps to |
|---|
| Verified caller | A claimed identity can be faked; verify it once at the edge, then trust it. | Gateway resolves the key to a user, stamps X-User-Id, strips any client-supplied identity. | Zero-trust edge / policy enforcement point (Google BeyondCorp) |
| Owned resources | Being logged in isn’t being allowed; check the object’s owner, not just the session. | meetings.user_id; a workspace is named for its owner. | Least privilege; prevents IDOR / broken object-level authZ (OWASP API1:2023 BOLA) |
| Check per request | A check you can skip isn’t a check; decide on every access, never in the UI. | OwnerOnlyPolicy on each read. | Complete mediation (Saltzer & Schroeder) |
| One decision point | Rules copied across services drift; keep one decider and have services call it. | identity_core decides; services enforce. | PDP/PEP separation (XACML lineage; OPA · AWS Cedar) |
| Default deny | A forgotten check should fail closed; deny unless explicitly allowed. | OwnerOnlyPolicy allows the owner, denies the rest with a reason. | Fail-safe defaults (Saltzer & Schroeder) |
| No shared keys | A master key can do everything; hand out a token good for one thing, briefly. | Signed dispatch tokens; credentials are brokered, never handed over. | OAuth 2.0 token exchange (RFC 8693); avoids the confused-deputy problem |
The agent (the LLM) is
outside the trust boundary — it carries proof but never enforces it.
Compromise it via prompt injection (
OWASP LLM01) and it still cannot exceed
its token’s scope; the boundaries enforce, not the model. See
Identity & trust.
The request path (grounded)
Everything reaches the domains through the gateway; the terminal is
one client. For every call the gateway resolves the key to a user and forwards to the owning domain with
X-User-Id stamped on.
terminal ──(API key, from your login)──▶ GATEWAY ──(X-User-Id, verified)──▶ meetings / agent domain
│ resolve key → user (fail-closed)
│ strip any client-supplied identity (anti-spoof)
│ check the route's coarse scope
The full public surface, grouped by what it protects — this is the real attack surface, and what the
security model has to cover (nothing more):
| Group | Endpoints | Owner rule | Scope |
|---|
| Front door | GET /health, GET /auth/me, WS /ws | n/a / “who am I” / per-meeting subscribe | — |
| Meetings (you must own the meeting) | GET·POST /bots, …/bots/{platform}/{native} (delete · config · speak), GET /transcripts/{platform}/{native}, GET /recordings…, GET /meetings… | owner = meetings.user_id | bot · browser · tx |
| Your agent space (yours by construction) | POST /agent/chat, GET /agent/meeting/stream, and /agent/* → chat, workspace/{tree,file,init,swap,git,upload}, sessions, routines, models, meeting/{start,process,stream} | owner = you, by partition (you can only name your own workspace/session) | none today |
Two safety shapes result:
- Agent space is safe by partition — a workspace, session, or routine is addressed by your id, so no
explicit check is needed.
- Meetings are owned in the meetings domain, so the owner-checked path runs there (
GET /transcripts,
/ws subscribe authorization).
The leak is where the agent domain reads a meeting directly instead of via the meetings domain — see
adoption gaps.
Building blocks
What actually exists today, with the real names.
identity_core — the pure rule library, sealed as identity.v1 (core/identity/contracts/identity.v1):
- Tokens (
tokens.py) — ScopedToken(subject, scopes, expires_at); scopes are bot · tx · browser;
mint_token / validate_token.
- Access (
access.py) — Resource(kind, id, owner) for kinds meeting_transcript · recording · ws_subscribe; AccessDecision(allow, subject, …, reason); OwnerOnlyPolicy (default-deny, allow iff
subject == owner); the front-door function can_access(subject, resource, action).
- Dispatch tokens (
dispatch_tokens.py) — a JWT-style, audience-scoped bearer token signed HS256 in
dev (SPIFFE SVIDs in production): DispatchClaims(subject, launcher, workspaces[], tools[], iat, exp); mint_dispatch_token / verify_dispatch_token. subject is who you act for;
launcher is what triggered it. may_mount(id, mode) and may_call(tool) are the limits a boundary
checks. This is the “single-purpose pass.”
- Secrets (
secrets.py) — the brokered-secrets pattern (HashiCorp Vault):
SecretsPort returns a redacted BrokeredSecret (its repr never prints the value) and writes an audit
log; the value is fetched on demand, never logged. The real vault (lease, rotation) is deferred — see
encryption.
admin-api — accounts and the live oracle:
- Tables:
User and APIToken(token, user_id, scopes[], expires_at, …); tokens look like
vxa_<scope>_<random>.
POST /internal/validate — the gateway calls this (behind an internal secret, fail-closed) to turn an
API key into {user_id, scopes, …}.
- Three tiers: admin (
X-Admin-API-Key, create users/tokens), user (X-API-Key, e.g. set a
webhook), internal (X-Internal-Secret, the validate oracle).
Gateway — the edge that resolves the key, stamps X-User-Id, enforces coarse route scopes, and runs
the /ws multiplex.
Where we are
Honest status. The design is frozen and the primitives are built — the gap is adoption: the rules
exist as a library but are not yet wired into every path that needs them. The constitution names this risk
directly: P20 (complete mediation) records that the canAccess seam “was designed but never wired, so
it rotted,” and P9 holds that an unenforced rule is only aspirational — a rule that does not turn
CI red can be crossed.
| Piece | State |
|---|
Per-dispatch signed token (DispatchClaims, HS256) | ✅ built, proven live |
identity_core library + sealed identity.v1 | ✅ built |
admin-api (users, tokens, /internal/validate) | ✅ built |
Gateway: key → user, X-User-Id, route scopes, /ws | ✅ built |
Agent-api derives subject from X-User-Id, ignores the client body | ✅ built (P20) |
| Client identity is real (terminal sends the logged-in user’s key via OAuth) | ✅ fixed — the old hardcoded u_live is gone from the client |
can_access / OwnerOnlyPolicy adopted on every meeting path | ⚠️ partial — meeting-api owner-checks its own way; the agent domain’s chat / meeting/stream / meeting/start / meeting/process do not call it yet |
| Server-side live-meeting owner attribution | ⚠️ pre-M2 — the live-meeting watcher is started with the default subject = "u_live" (control_plane/api.py:880 → transcription_watcher.py:203); live-meeting dispatch (M2) is not yet delivered, so this default must be replaced with the real owner before it ships |
| Auth spine end-to-end (opaque token → user → scoped access, everywhere) | ⬜ planned (Stage 2) |
| Workload identity in production (SPIRE), Keycloak, RFC 8693, MCP Gateway | ⬜ planned (Stage 2) |
| SSO (Okta/Entra) + SCIM | ⬜ planned (Stage 5) |
The front door is real; a few agent-domain endpoints read a meeting’s data directly instead of via the
meetings domain, skipping the owner check that already exists in the library. Closing it is wiring, not
design: route those reads through the owning domain and decide with OwnerOnlyPolicy.
Where we are going (zero-trust)
The target — a chain of custody where a dispatch’s authorization is proven and verified at every hop
(signed tokens, RFC 8693 exchange at the tool boundary,
SPIRE/Keycloak via kagenti) — is one document:
Identity & trust. It lands as Stage 2 — Trust.
The point for this domain is narrow: we run an untrusted, prompt-injectable agent on private data, so
safety must come from boundaries that verify, not from trusting the model.
Roadmap — by principle
Each item ties a declared principle to its code reality and the concrete next step, plus the gate or stage
that makes it real (P9: an ungated rule is aspirational). Foundations:
the agent is untrusted, identity is a chain of custody,
self-host & air-gap by default.
| Principle | Today (in code) | Next step | Lands as |
|---|
P20 — complete mediation: authorize every access, default-deny canAccess on API · WS · agent | deny test on the library only (core/identity/tests/test_access.py, gate:access); meeting-api owner-checks implicitly per query; agent meeting paths unchecked (can_access count = 0 in control_plane/api.py) | wire can_access(subject, resource, action) onto every meeting path (chat · meeting/stream · meeting/start · meeting/process); extend gate:access to the wired paths | ADR-0012 · gate:access |
| Identity is a chain of custody | x-user-id is plaintext, trusted by network position (gateway/app.py:192); the per-dispatch token is minted (control_plane/dispatch.py:36) but not yet verified at a boundary | a signed user-assertion verified at each hop; verify the dispatch token at the workspace/tool boundaries | Stage 2 — SPIRE · Keycloak · RFC 8693 · MCP Gateway |
| Scoped agent domain | /api/* carries no scope — any valid key reaches any agent route (gateway/app.py:153-156) | per-route scopes for the agent domain | Stage 3 |
| P15 — secrets behind a port | broker pattern wired for the per-user git token (agent/shared/adapters.py:135-162); the store is the stand-in PassthroughSecretsBroker | real vault — lease · rotation · BYOK — behind SecretsPort | P16 · ADR-0003 |
| P15 — data encrypted at rest | buckets bind-mounted; transcripts and tokens in cleartext | the three at-rest stores → see Data at rest | Stage 0 contract · planned |
| Multi-tenant attribution | the live-meeting watcher is started with the default subject = "u_live" (control_plane/api.py:880 → transcription_watcher.py:203,339); live-meeting dispatch (M2) is not yet delivered | arm the copilot as the real meeting owner before M2 ships | Auth spine · M2 |
| P9 / P6 — one front door | _resolve_user_id is duplicated across four meeting-api routers (collector · bot_spawn · lifecycle · recordings) | fold identity resolution into the shared identity front door | cleanup |
subject_of (control_plane/api.py) has a VEXA_AGENT_DEFAULT_SUBJECT fallback for a gateway-less
single-user self-host. It is fail-closed (401) when unset; it must stay unset in any multi-tenant
deployment, or every caller collapses to one subject.
Data at rest (encryption)
Three stores hold sensitive data at rest (data in transit is encrypted with TLS — the standard that
secures HTTPS connections). State and plan:
| Store | What is in it | Today | Plan |
|---|
| Workspace buckets | Your whole workspace — notes, meeting docs, chat transcripts — a git folder in object storage (minio/S3) | bind-mounted local dirs (M1); contract reserves an encryption field on workspace.v1 | Per-workspace envelope encryption (the KMS data-key pattern): a data key per workspace, wrapped by a master key the identity layer brokers; decrypted only inside the sandboxed container; keys stay in-VPC for air-gap |
| Transcripts | The durable source of truth: the meetings Postgres transcriptions table (what GET /transcripts reads). The redis transcription_segments / tc:…:mutable stream is the live carrier; the workspace Markdown is a derived copy | persisted in Postgres; carried in redis; derived copy in the workspace bucket | Encrypt at rest in the meetings database (the SSOT); protect the redis carrier (auth/TLS, restricted ACLs); the workspace copy is covered by bucket encryption above |
| User API tokens | The keys in api_tokens.token that unlock an account | stored and matched as cleartext | Store only a hash and verify by hashing the presented key — the same practice as passwords (argon2 / bcrypt) — or encrypt at rest, so a database leak yields no usable keys |
These are tracked in the roadmap status; workspace/bucket encryption is already part of
the workspace.v1 contract shape in Stage 0.
Why this much, and not more
Measures are sized to the system: a multi-user product where each person owns meetings and a private
workspace, agents run untrusted in isolated containers, all driven from the terminal. The API reduces to
“is this yours?”, so the priorities are:
- owner checks on meetings and workspaces (cross-user exposure is the real risk);
- credentials out of the agent (the one untrusted, injectable component);
- encryption of the three at-rest stores.
Heavier machinery — SPIRE everywhere, SSO/SCIM, full air-gap — is staged (Stage 5) for
self-host and regulated verticals. The interfaces (can_access, the token shapes) do not change when it
lands; only what sits behind them does.