ADR-008: Suite identity — the apps claim, principals, roles, and signing
Source of truth:
docs/adr/008-suite-identity-roles-and-principals.md(synced at build time — edit the original, not this copy). ADR-010 partial supersession (2026-07-30): D1–D11, the rollout aliases, product-native role authority, default deny, and shipped #144–#149 evidence retain their exact historical meanings. Public non-default Organization production, selection/lifecycle, and the future MSSP seam in Amendment 8 are no longer forward product targets. Public authority is fixed to literal realmdefault;suite_org_idmay remain only as an authenticated internal compatibility/security scope. See ADR-010.
Status: Accepted (2026-07-27; historical contract restored 2026-07-27). This ADR
is the canonical D1–D11 identity/role contract that shipped VectorFlow, CHAD,
and Warden source cite by number — ADR-008 D9, D2/D7, Phase 1 step 7, and so
on. An earlier reconstruction of this file renumbered the contract into six new
“Decision N” entries; those numbers were semantically broken against the numbered
citations in code (e.g. ADR-008 D9 means team-ownership propagation, not the
reconstruction’s “signing”), so D1–D11 are restored here with their original
meanings (issue #127). The 2026-07-27 owner canonicalisation is preserved as
dated amendments / approved targets in a separate section below; it does not
reuse the D1–D11 identifiers.
Amended by ADR-016: every product gets an approver role, and the active team moves into the signed session. D8, D9 and D11’s narrow-never-widen rule stand.
Builds on ADR-003 (Veltro is the suite identity authority) and ADR-004/ADR-006 (the suite-service token plane); nothing in those is retracted.
Context
Section titled “Context”ADR-003 made Veltro the suite identity authority and ADR-004/006 defined the
short-lived suite-service token plane. The per-app authorization model VectorFlow
actually ships — the apps session claim (computeAppsClaim) with product-native
roles, plus the CHAD/Warden decoder in veltro-suite-auth — was rolled out under
numbered decisions D1–D11 and numbered rollout Phase 0–3 / step aliases.
Source comments, migrations, tests, and models across the three product repos cite
those identifiers directly (the historical distribution: D9 dominates with the
team-ownership propagation surface, D11 the shared active-team switcher, with
D1–D8/D10 and the Phase/step aliases spread across the identity producer, decoder,
consumers, and migrations). The reconstructed six-decision file left every one of
those citations pointing at the wrong meaning. This document is the authoritative
restatement.
The decision statements below record implemented behaviour unless a claim is explicitly marked a target or drift; the 2026-07-27 amendments section is where the owner-approved forward targets live.
Historical contract (D1–D11)
Section titled “Historical contract (D1–D11)”D1 — Product-native role and permission authority
Section titled “D1 — Product-native role and permission authority”Each product owns its native role vocabulary, privilege ordering, and editable
role→permission semantics. A collapsed User.role column may back
compatibility/display, but it MUST NOT replace effective per-team authorization.
Source: CHAD backend/app/services/suite_auth.py:_CHAD_ROLE_RANK +
backend/app/services/team_scope.py (own ordering collapses to users.role for
display/compat only); Warden backend/app/api/permissions.py,
backend/app/services/permissions.py, backend/app/models/role_permission.py — the
DB-backed, admin-editable role→permission matrix stays app-owned in both standalone
and delegated mode (only who holds a role is suite-owned). [code]
D2 — Per-app grants and suite team mirroring
Section titled “D2 — Per-app grants and suite team mirroring”apps.<product> carries a nullable org-wide grant (org_role) plus enumerated
per-team grants. CHAD and Warden mirror the suite-canonical teams and reconcile
their many-to-many memberships from the claim. Source: VF AppGrant model +
src/server/services/suite/app-roles.ts (AppsClaim shape) + computeAppsClaim;
CHAD migration backend/alembic/versions/20260721a_suite_team_membership.py; Warden
backend/app/models/team.py (D2/D8/D9) + backend/app/services/suite_auth.py:_sync_suite_user
(D2/D6). [code]
D3 — Native interactive roles; separate coarse M2M bridge
Section titled “D3 — Native interactive roles; separate coarse M2M bridge”Interactive roles are product-native (from apps.<product>). The coarse
admin/editor/viewer suite-service actor wire remained unchanged and separate
during the rollout — its coarse-map retirement was deferred to a later decision, so
the machine-to-machine wire format (no team, coarse role) stayed frozen. Source: VF
src/server/mcp/product-client.ts (frozen actor wire, “migration path TBD in later
decision”); src/server/services/suite/app-roles.ts (per-app native role vocab as
the TS contract, D3); veltro-suite-auth session.py (per-app native role
vocabularies, D3). [code]
D4 — Default deny
Section titled “D4 — Default deny”Missing, malformed, empty, or unknown apps claims — and legacy suite_role-only
sessions — grant no product access (fail closed, 403). Org OWNER/ADMIN receive
explicit implicit suite-admin access from the producer (VF stamps admin
grants in the claim), never as a consumer fail-open. Source: veltro-suite-auth
session.py (absent/malformed entry rejected, not treated as an implicit grant);
VF computeAppsClaim (org OWNER/ADMIN → implicit admin in every app); CHAD/Warden
resolve_vf_user (403 when the relevant apps.<product> grant is absent). [code]
D5 — Org-wide team directory in the claim
Section titled “D5 — Org-wide team directory in the claim”An org-wide grant still enumerates the accessible team directory so consumers can
decode, mirror, and populate switchers. Team names MAY be omitted to stay under
cookie size limits, but each team’s id and role remain mandatory. Source:
veltro-suite-auth session.py (D5 decode-only team directory carried even for
org-wide users); shell packages/shell/src/team-switcher.tsx (lists the directory).
[code]
D6 — Delegated suite authority and lockdown
Section titled “D6 — Delegated suite authority and lockdown”The historical contract makes the signed suite claim authoritative: consumers
re-sync role and team memberships on every request, and local
identity/SSO/team-mutation surfaces are locked down so local state cannot diverge
from the claim. Membership reconciliation and delegated-mode lockdown are shipped,
but role synchronization has one explicit implementation drift: CHAD
backend/app/services/suite_auth.py:resolve_vf_user refuses to demote the sole active
CHAD administrator, audits auth.suite_role_sync_blocked, reconciles teams, and
retains the local admin role. Warden
backend/app/services/suite_auth.py:_sync_suite_user reconciles role and memberships
to the claim; CHAD/Warden backend/app/api/deps.py:block_in_delegated_mode blocks
local mutation surfaces. [code + drift]
D7 — Preserve per-team roles and group-mapping provenance
Section titled “D7 — Preserve per-team roles and group-mapping provenance”Per-team app-native roles are never lossily collapsed; the effective role uses the
product’s own ordering / highest applicable grant (“highest wins”). Suite SSO group
mappings create team/app grants, while MANUAL grants are never touched by
reconciliation. Source: VF src/server/services/suite/group-mappings.ts (highest
role per slot wins; MANUAL rows never created/updated/deleted here) +
src/server/services/auth/suite-role.ts:higherRole + the GroupMapping model;
veltro-suite-auth session.py (distinct per-team roles preserved, D7). [code]
D8 — Explicit resource ownership classes and global authority
Section titled “D8 — Explicit resource ownership classes and global authority”Team-owned resources and intentionally org-wide / shared resources are explicit
ownership classes. Org-wide authority derives from an org-wide grant, never from a
collapsed high role held on a single team. Source: Warden backend/app/models/team.py
(team dimension + org-wide resources kept explicit, D2/D8/D9); CHAD alert
team-stamping (org-wide iff team is None). [code]
D9 — Propagate and enforce team ownership end to end
Section titled “D9 — Propagate and enforce team ownership end to end”The historical invariant is that ownership follows resources across every seam —
alert → case → run, dedupe, retries, and approvals. A NULL team means
deliberately org-wide, not accidental widening; creation and redelivery paths
must fail closed rather than leak a team-owned resource into org-wide scope. A
team-only grant cannot mint a global actor token.
This is partially implemented with known drift. CHAD
backend/app/api/logs.py, backend/app/services/{alerts,log_processor,pull_detector,notification,warden_reconciler}.py
stamp and propagate the owning team, and backend/app/api/warden.py requires an
org-wide Warden grant for a global actor token. However, Warden
backend/app/services/team_scope.py:resolve_payload_team currently maps an absent,
malformed, or empty inbound team identity to None (org-wide), and
backend/app/schemas/webhook.py:ChadTeamRef.id does not reject an empty id. Those
paths do not yet satisfy the fail-closed historical invariant. [code + drift]
D10 — Coordinated migration/release ordering
Section titled “D10 — Coordinated migration/release ordering”Publish the claim contract + decoder first, seed equivalent app grants, then cut the
consumers over — preserving prior effective access without temporarily accepting
the retired coarse suite_role session claim (no compatibility fallback). Source: VF
migration prisma/migrations/20260721180500_seed_app_grants/migration.sql (seeds
equivalent grants so users keep effective access after cutover, ADR-008 D10);
veltro-suite-auth tests/test_session.py (a legacy suite_role-only session gets
NO access, D10). [code]
D11 — Shared active-team context narrows views only
Section titled “D11 — Shared active-team context narrows views only”The active team is view context, not authority. A valid selection MAY narrow the
view but MUST NEVER widen access. A missing, stale or ungranted selection falls
back to everything the user’s grants reach (All teams) without an error.
Since veltro#1148 the selection lives in the identity session (ADR-016 D21). The
chrome’s header switcher (@veltrosecurity/shell 1.9.0 TeamSwitcher) posts
{ team: <id> | null } to identity’s POST /api/chrome/team, which re-issues the
session with the same sign-in time and expiry. It accepts only one of the viewer’s
teams: every directory team with a realm grant in any product, otherwise the teams
their team grants name. At every mint identity resolves the stored pick against
those teams again and signs it as grant.selected_team_id in the browser request
assertion, or null for all teams, a stale pick, and personal access tokens. It
never changes which grant is bound. Each product narrows its lists to the signed
team only when the user’s grants there already cover it. The same-origin
veltro_active_team cookie, which no backend could read behind the gateway, is
gone, along with the shell’s resolveActiveTeam first-team fallback. [code]
Historical rollout aliases
Section titled “Historical rollout aliases”Source also cites the rollout by phase/step aliases. These resolve as:
| Alias | Resolves to | Rollout artifact |
|---|---|---|
| Phase 0 | Publish the strict apps claim contract + Python decoder first |
VectorFlow PR #575 |
| Phase 1 | VF identity producer: AppGrant/GroupMapping admin model, session minting, seed migration, shared switcher |
VectorFlow PR #579 (its title accidentally says “ADR-007”; body + commits are ADR-008) |
| Phase 2 | CHAD consumer cutover: team mirroring, delegated lockdown, ownership propagation | CHAD PR #329 (D11 correction #331) |
| Phase 3 | Warden consumer cutover: team tenancy + native permission matrix | Warden PR #37 (D11 correction #40; hardening #45) |
| step 3 | Downstream AppGrant target / model (per-app, per-team grant) |
VF prisma/schema.prisma (SuiteApp covers only downstream apps) |
| step 7 | Per-user grant administration surface (suite RBAC admin) | VF src/server/routers/suite-rbac.ts, user-access-dialog.tsx |
2026-07-27 amendments and approved targets
Section titled “2026-07-27 amendments and approved targets”The owner canonicalisation on 2026-07-27 layered the following on top of D1–D11. Each is tagged current (implemented state, possibly drift) or target (approved, not yet built). These are amendments; they do not renumber or replace D1–D11.
Amendment 1 (2026-07-27) — apps.<product> interactive roles are authoritative — current
Section titled “Amendment 1 (2026-07-27) — apps.<product> interactive roles are authoritative — current”Product-native apps.<product> interactive roles are the canonical interactive
authorization model; the stale single-coarse-suite_role interactive narrative is
retired (no mint path stamps a lone suite_role; the suite-role.ts docblock that
says otherwise is stale drift). Reaffirms D1/D3/D7 as the interactive contract.
Amendment 2 (2026-07-27) — grant-revocation latency — current debt / target #129
Section titled “Amendment 2 (2026-07-27) — grant-revocation latency — current debt / target #129”Interactive grants are captured once at sign-in and may remain valid for the full 24-hour session lifetime today; per-request re-sync trusts that signed claim, so a mid-session revocation is not immediate. Target: app removal, role demotion, and team-grant removal take effect across active sessions within ≤60 seconds (#129).
Amendment 3 (2026-07-27; completed 2026-08-07) — standalone remains supported — current
Section titled “Amendment 3 (2026-07-27; completed 2026-08-07) — standalone remains supported — current”Standalone product operation (own login/SSO/SCIM, no Veltro-identity dependency)
remains a first-class supported mode. The profile/env default that enabled delegated
auth (make up PROFILE=… inheriting VELTRO_INTEGRATED=true from the generated
.env) is fixed (#128): VELTRO_INTEGRATED is a required input of the startup
command rather than a compose default, the environment template no longer carries
it, make derives it from PROFILE, and scripts/profile_auth_isolation.py gates
both modes in CI.
Amendment 4 (2026-07-27) — four principal kinds, explicit attribution/scope — current
Section titled “Amendment 4 (2026-07-27) — four principal kinds, explicit attribution/scope — current”The suite distinguishes four principal kinds, each with its own attribution and scope
semantics: interactive session principals (Auth.js JWE cookie, native role from
apps.<product>), MCP principals (PAT/OIDC, role resolved live per /mcp
request), suite-service actor tokens (≤60s scoped JWS carrying an acting human’s
attribution), and actorless service tokens (machine data planes, no human actor).
Conflating them — e.g. attributing an analyst’s action to a machine account — is drift
tracked by Amendment 7.
Amendment 5 (2026-07-27) — per-service asymmetric signing — current debt / target #130
Section titled “Amendment 5 (2026-07-27) — per-service asymmetric signing — current debt / target #130”Today one shared symmetric secret (VELTRO_SESSION_SECRET = NEXTAUTH_SECRET) is
fanned to Veltro, VF, CHAD, and Warden — a single signing trust domain whose transitive
compromise radius lets any secret-bearing process forge browser sessions and
suite-service tokens. This is implemented debt. Target: per-service
asymmetric signing to bound the blast radius; audience/minter claims stay
authorization metadata, not a substitute for cryptographic key separation (#130).
Amendment 6 (2026-07-27; completed 2026-07-29) — Veltro-owned MCP actor delegation — current #131
Section titled “Amendment 6 (2026-07-27; completed 2026-07-29) — Veltro-owned MCP actor delegation — current #131”Veltro owns the canonical suite actor-delegation authority contract: its claim semantics, versioning, authenticity requirements, product/scope binding, attenuation rule, rejection behaviour, conformance vectors, and released-matrix gate. Runtime production is separate ownership: the Veltro identity role and MCP plane currently run from the shared VectorFlow implementation and emit the short-lived token, but may only preserve or narrow authority derived from the authenticated Veltro principal.
For product-scoped chad:* and warden:* calls, contract v2 is the complete
tuple actor_role_contract_version=2, actor_app, and exact native
actor_app_role, alongside the transitional coarse actor_suite_role. The
product must match the scope family and the native/coarse roles must agree.
Missing, partial, wrong-type, unsupported-version, wrong-product, unknown-role,
or conflicting tuples fail closed before JIT or protected side effects.
Consumer-first deployment retains one bounded legacy case: a token may omit the entire v2 tuple while the old producer remains deployed, in which case consumers apply the existing coarse actor mapping. Presence of any v2 field makes the full strict tuple mandatory. The released v2 producer always emits the complete tuple; legacy absence is migration compatibility, not an alternate authority contract.
Veltro authoritatively assigns the product-native role; CHAD and Warden remain authoritative for what that role permits against their live local resources. The envelope therefore carries no product permission booleans and grants no RBAC or route-scope bypass. Team-scoped and non-default-Organization MCP actor delegation remain outside this contract. The coarse field remains only for the bounded rolling-compatibility window and may not be removed until every released consumer uses the canonical typed decoder.
Amendment 7 (2026-07-27) — dual service + human attribution — target #141
Section titled “Amendment 7 (2026-07-27) — dual service + human attribution — target #141”Cross-product mutations MUST preserve both the transport-service identity and
the initiating-human attribution on each receiver’s own audit trail. Current
behaviour attributes suite-token (external:*) writes to a fixed machine account
(suite-service@veltro.internal), losing the acting analyst. Retaining both is the
approved target (#141).
Amendment 8 (2026-07-27) — canonical suite organization identity — target #59/#144
Section titled “Amendment 8 (2026-07-27) — canonical suite organization identity — target #59/#144”The immutable string suite_org_id is the canonical cross-product
organization identifier. VectorFlow’s Organization.id is authoritative for the
suite value. CHAD and Warden keep product-local primary keys and resolve the suite
identifier through a unique, immutable suite_org_id on their local Organization
rows; they do not convert their local primary keys to strings and do not derive them
by hashing or UUID mapping. The solo-install identity is the literal
suite_org_id = "default" and follows the same model and authorization path as
every other organization.
The signed interactive session identifies exactly one active organization. The
identity producer may mint that organization only after a server-side lookup proves
an active membership between the current user and the VectorFlow Organization. A
Host, organization slug, request parameter, active-team cookie, or other
client-selected value may help route the request but is never organization
authority. A consumer must resolve the signed suite_org_id to its local
Organization before JIT identity creation, reconciliation, authorization,
credential-use bookkeeping, or other side effects. Missing, empty, malformed,
conflicting, or locally unknown identifiers fail closed with the consumer’s generic
not-found response. No globally unique child identifier is an authorization shortcut.
Organization, Team, and Environment describe different hierarchy levels and UX.
Organization is the suite-wide customer boundary and future MSSP scaling seam.
VectorFlow may partition logging operations by Team and Environment inside that
Organization and expose selectors for those contexts. CHAD and Warden each present
one Security Team application context inside the active signed Organization and do
not expose Organization, Team, or Environment selectors. Their decoded Team grants
may authorize entry and preserve native ownership checks, but do not establish a
caller-selectable product data partition. Any future MSSP Organization selection
belongs to VectorFlow/the suite identity context before minting; CHAD and Warden
consume the resulting signed suite_org_id and never accept a local Organization
UUID, slug, hostname, or similarly named VectorFlow Team as switching authority.
Versioned browser-session transition
Section titled “Versioned browser-session transition”The existing session is the v1 organization contract: it has no
session_contract_version, carries org_id = "default", and cannot authorize a
non-default organization. The target v2 organization contract carries:
{ "session_contract_version": 2, "suite_org_id": "<opaque-suite-organization-id>", "org_id": "<same-value-during-the-rolling-transition>"}suite_org_id is a non-empty, 1–255 character opaque identifier matching
[A-Za-z0-9][A-Za-z0-9._:-]{0,254}. For v2 it is authoritative. While org_id
is retained for rolling compatibility it MUST equal suite_org_id; disagreement
invalidates the session rather than selecting either value. A claim with
suite_org_id but no supported session_contract_version, an unsupported version,
or a non-default v1 org_id is invalid. A v2-aware decoder may normalize a valid v1
session only to suite_org_id = "default"; the legacy field can never introduce
non-default authority.
The temporary duplicated field is a compatibility encoding, not two authorities:
old decoders continue to accept only org_id = "default" and therefore reject a
non-default v2 session, while new decoders require equality. After all consumers
run the v2 contract and at least the maximum 24-hour v1 session lifetime has
elapsed, a later contract release removes v1/org_id support. This compatibility
window is specific to organization identity and does not reintroduce D10’s retired
coarse suite_role fallback.
Coordinated rollout
Section titled “Coordinated rollout”- Publish the shared
veltro-suite-authdecoder/conformance contract with strict v1 normalization and v2 validation; do not change the producer yet (#145). - Add and backfill CHAD (#146) and Warden (#147) Organization mappings, then
deploy consumers that resolve
SessionClaims.suite_org_idbefore side effects. Existing records may map to"default"only when the migration proves they belong to the solo installation; ambiguous records fail migration rather than silently defaulting. - Prove every consumer accepts valid v2 default sessions, rejects malformed or conflicting claims, and rejects locally unknown organizations (#146/#147).
- Update VectorFlow last: resolve an active
OrgMemberserver-side, then mint v2 behind consumer readiness (#148). Non-default minting remains prohibited until step 3 is complete across every consumer. - Gate non-default enablement on cross-product conformance (#149). After the
compatibility window, retire v1 and the duplicated
org_idfield in a separately versioned contract release.
This extends D10’s decoder-before-consumer-before-producer discipline without
renumbering or changing D1–D11. Steps 1–4 and step 5’s non-default enablement
gate completed with protected conformance runs 30311900642 and 30313548040
plus exact suite 0.4.3 promotion run 30313645213. Step 5’s v1 retirement
remains prohibited until the recorded 24-hour compatibility window has elapsed
and requires a later separately reviewed release, as documented in
../suite-org-v2-rollout.md.
Amendment 9 (2026-08-09) — CHAD alert writes join the actor plane — current #362
Section titled “Amendment 9 (2026-08-09) — CHAD alert writes join the actor plane — current #362”The product-scoped chad:alerts:write family grants
chad:alerts:read and chad:alerts:write. It uses Amendment 6’s unchanged
contract-v2 actor tuple: actor_app is chad, the exact CHAD-native role and
coarse projection must agree, and missing, partial, wrong-product, unknown-role,
or conflicting authority fails closed. Because this family postdates the v2
producer rollout, Amendment 6’s bounded old-producer full-tuple-absence
compatibility does not apply to it; receivers reject that absence. external:*
receives no actor binding.
Human-initiated Warden chad.comment actions move from external:write to
chad:alerts:write. Rule snoozes and exceptions remain on their existing
actor-capable chad:rules:deploy and chad:rules:write families. The
machine-initiated alert case-reference writeback remains actorless on
external:write, as do the suite’s other machine data-plane lanes.
Amendment 10 (2026-09-26) — core plus selected products — ADR-014
Section titled “Amendment 10 (2026-09-26) — core plus selected products — ADR-014”ADR-014 supersedes Amendment 3’s
support for product-only Veltro profiles and local product sign-in. A one-product
installation includes Veltro identity and its single-origin gateway. The
installer selects any subset of VectorFlow, CHAD and Warden with
VELTRO_PRODUCTS; an empty set runs core alone. full and the old
--profile vectorflow|chad|warden install choices are retired. Products receive
browser assertions for a Veltro identity with an explicit product grant; a
realm administrator has no implicit product grant.
Amendment 3 records the earlier supported deployment and its safety fix. It
remains here as history, not current install guidance. The product repositories
remove local login, product-owned SAML/OIDC and the VELTRO_INTEGRATED switch
as their step-4 releases are pinned. Until then, the images may still contain
dual-mode code, though Veltro does not offer a product-only install. The
product-native role and permission authority in D1–D11 is unchanged.
Consequences
Section titled “Consequences”- Numbered code citations (
ADR-008 D1–D11,Phase 0–3,step 3/step 7) now resolve to this document with their original meanings; the six-decision reconstruction is superseded. - The 2026-07-27 owner targets live in the amendments section and are mirrored in
../traceability-matrix.mdand../golden-paths.md; nothing there or here claims a target has shipped. - Reference integrity between this ADR and the numbered citations in the four suite
repos is enforced by
../../scripts/check-adr008-reference-integrity.py: it inventories every ADR-008D<n>/Decision <n>/Phase <n>/step <n>qualifier in the supplied paths, pins the normalized D1–D11 headings and rollout-alias meanings in a separate compatibility manifest, and fails on unresolved identifiers or scan I/O errors. Run it against all four linked worktrees before changing decision or alias semantics in this file. - Cross-product organization identity now has one explicit authority and migration path. Product-local keys remain implementation details; no product may infer organization authority from routing state or silently map an unknown suite id to its default tenant.