Skip to content

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 realm default; suite_org_id may 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.

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.

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]

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]

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.

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.

  1. Publish the shared veltro-suite-auth decoder/conformance contract with strict v1 normalization and v2 validation; do not change the producer yet (#145).
  2. Add and backfill CHAD (#146) and Warden (#147) Organization mappings, then deploy consumers that resolve SessionClaims.suite_org_id before 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.
  3. Prove every consumer accepts valid v2 default sessions, rejects malformed or conflicting claims, and rejects locally unknown organizations (#146/#147).
  4. Update VectorFlow last: resolve an active OrgMember server-side, then mint v2 behind consumer readiness (#148). Non-default minting remains prohibited until step 3 is complete across every consumer.
  5. Gate non-default enablement on cross-product conformance (#149). After the compatibility window, retire v1 and the duplicated org_id field 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.

  • 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.md and ../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-008 D<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.