Files
harmony/docs/adr/025-device-secret-access/025-3-groups-as-security-boundary.md
Jean-Gabriel Gill-Couture 397f266d31 docs(adr-025): groups are the security boundary — rewrite, trail, guide
Rewrite ADR-025 around the group-based design: groups gate (admin-
managed identity), labels place, OpenBao enforces via external groups;
batch tokens with 1h TTL make expiry re-login the membership sync.
Iteration trail preserved under 025-device-secret-access/ (025-1 JWT
claim scoping, 025-2 entity-by-policy as built, 025-3 the scale and
security analysis with verified sources). Add the user-facing guide
and the implementation + branch-consolidation rollout plan.
2026-06-12 00:26:24 -04:00

178 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR 025-3: Groups as the Security Boundary — Scale & Security Analysis
Initial Author: Jean-Gabriel Gill-Couture
Initial Date: 2026-06-11
## Status
Accepted — this analysis produced the design in the main
[ADR-025](../025-fleet-device-secret-access.md). Kept as the record of
the entity-vs-groups comparison, the verification work behind it, and
the state-of-the-art survey.
## Context
[025-2](./025-2-entity-by-policy.md) (entity-by-policy) shipped first
and is correct at small scale, but two concerns drove a re-design:
1. **Privilege escalation via labels.** Scheduling — and therefore
secret grants — keyed on a label selector over *device-reported*
labels. A compromised device relabels itself to match any
deployment's selector and receives its secrets. Kubernetes has the
same structural risk and closes it with the `NodeRestriction`
admission plugin; we had no equivalent, and no operator-facing way
to say "this deployment may only ever be seen by the lab fleet."
2. **Write amplification.** The operator rewrote one OpenBao identity
entity per targeted device per deployment change — O(targeted
devices) Raft-backed writes. The fleet ambition is millions of
devices.
The proposal: split authorization from placement, as Kubernetes splits
RBAC from scheduling. Groups (admin-managed, never self-asserted) gate
who may *view* a deployment; labels only place workloads within the
authorized set. Two enforcement variants were compared:
- **A — external groups:** Zitadel group membership rides in the token
(`groups_claim`); OpenBao maps it to external identity groups; each
deployment's policy attaches to its allowed groups.
- **B — entity-by-policy + group gate:** keep 025-2's per-device
entity policy lists; groups exist only in the control plane as a
scheduling filter.
An initial review recommended B (finer granularity, no re-login,
instant server-side revocation). Verification of the underlying
facts — plugin source, documented limits, at-scale precedent —
reversed that recommendation. **A wins at the stated scale.**
## Verified facts (with sources)
1. **Token renewal does NOT re-sync external-group membership for
JWT auth.** The plugin's renew handler only checks the role still
exists and extends TTLs; claims are never re-read, `GroupAliases`
never rebuilt. Membership converges **only at fresh login**.
HashiCorp's KB wording ("login or renewal") is true for methods
whose renew path re-queries the IdP (e.g. LDAP), not JWT.
Source: `vault-plugin-auth-jwt/path_login.go` and OpenBao's fork
`builtin/credential/jwt/path_login.go`.
2. **Identity policies resolve at request time.** "The evaluation of
policies applicable to the token through its identity will happen
at request time" (Vault/OpenBao identity concepts). Attaching or
detaching a policy on a group is visible to every member's existing
tokens immediately.
3. **Batch tokens carry identity.** OpenBao marshals `EntityID` into
the encrypted batch-token blob (`vault/token_store.go`), so
login-issued batch tokens resolve entity and external-group
policies per request — with **zero storage writes per login**.
Limits: no renewal, no accessor, not individually revocable.
(Token-backend-created batch tokens have no entity — the source of
conflicting community lore.)
4. **Entity disable is the per-device kill switch.**
`identity/entity` `disabled=true`: "associated tokens cannot be
used, but are not revoked" — request-time blocking, no device
cooperation, works for batch tokens.
5. **Identity store ceiling.** Entities pack into **256 storage
entries** (hard cap ≈ 0.51.25M entities at default limits,
~200 B/entity), the store is memory-resident (go-memdb), and each
entity upsert rewrites its whole shard — per-write cost grows with
fleet size. Community reports: degradation from ~50k identities;
5.7M entities → ~110 GB RAM and >10 min failover
(hashicorp/vault#8761). **JWT login unavoidably creates one
entity + alias per distinct `user_claim`** — under any design.
6. **Write throughput.** Reference-architecture benchmark ≈ 720
storage writes/s; >1k req/s is "high volume" guidance for
integrated storage. No bulk-entity-update API exists.
7. **Zitadel.** Machine-JWT token issuance benchmarked ≈ 437/s
(v2.66) to ≈ 852/s (v4-rc); roles claim
(`urn:zitadel:iam:org:project:roles`) is a **map**, while
`groups_claim` requires a **string array** → flattening Action
needed until first-class groups (epic zitadel#9702, in flight,
machine users included) ship a documented array claim.
8. **At-scale precedent — AWS IoT Core.** Policies attach to thing
groups; "policies are dynamically applied to things when they
change group membership"; Greengrass deploys secrets by targeting
thing groups. Group-attached policy *is* the canonical hyperscale
IoT authorization model. Azure scopes to per-device/module
identities; no per-workload grant materialization anywhere.
## The decisive arithmetic
Let a deployment target M devices in a fleet cell.
| | B: entity-by-policy | A: groups |
|---|---|---|
| Writes per deployment change | M entity upserts; each rewrites ~1/256 of all entities → at M = 10⁵, gigabytes of Raft churn, minuteshours of convergence, partial-failure management | 1 policy attach per allowed group, instant for all existing tokens (fact 2) |
| Writes per membership change | 1 (instant) | 1 Zitadel write; converges at next login ≤ TTL (fact 1) |
| Steady-state auth load | ~0 (long-lived tokens viable) | N/TTL logins/s — 1M devices @ 1 h ≈ 280/s, stateless with batch tokens (fact 3); Zitadel-side token reused across logins (~23 mints/s @ 12 h validity) |
| Per-device emergency cutoff | strip entity policies | disable entity (fact 4) — equivalent |
| Compromised-device blast radius | deployments scheduled on it | all deployments its groups may view — by declaration; granularity = group sizing |
| IdP machinery | none | flattening Action until Zitadel groups GA (fact 7) |
B's write cost scales with *deployment churn × fleet size*; A's with
*membership churn*. Membership is the rare, per-device event —
deployments are the frequent, fleet-wide one. The data plane already
fans out O(M) per deployment (NATS desired-state KV), but NATS KV
sustains ~10⁵ writes/s where OpenBao identity writes run ~10²10³/s
and degrade as the store grows: per-device *instructions* scale;
per-device *grants* don't. B's apparent security edge largely
dissolved under verification: per-device hard revocation exists in A
(fact 4), routine de-grouping ≤ TTL is an operational tolerance, and
finer isolation is available by defining finer groups. What remains is
A's group-wide secret visibility — accepted explicitly, since
`allowedGroups` *is* the declared audience.
A corollary of fact 1: don't renew tokens at all. Short-TTL batch
tokens whose natural expiry forces re-login make the login itself the
membership sync — deleting the re-login-signal mechanism the first
sketch of this proposal included.
Independent of A vs B, fact 5 caps any single OpenBao at ~10⁵10⁶
device entities: "millions of devices" means **cell sharding** (one
Zitadel project + OpenBao pair per cell) under either design. Groups
shard naturally with cells.
## State of the art surveyed
- **Vault-ecosystem fleet patterns** — AppRole + trusted orchestrator,
TLS-cert + SCEP (Vault 1.20), SPIFFE auth (Vault 1.21, not in
OpenBao) — all solve device *identity*, which Zitadel already
provides; none address per-workload scoping. Nothing resembling
"a deployment declares which device groups may read its secrets"
exists in that ecosystem; the model is uniformly identity → policy
→ path.
- **Kubernetes `NodeAuthorizer`** — per-node grants derived from pod
placement, the inspiration for B — materializes nothing: it is an
in-memory request-time graph in the API server. The faithful OpenBao
equivalent would be a custom request-time authorization plugin,
rejected for coupling secret reads to control-plane liveness.
- **Templated policies** (`claim_mappings` → alias metadata → one
fleet-wide policy `…/{{identity.entity.aliases.<accessor>.metadata.group}}/*`)
achieve zero grant writes ever — AWS IoT's policy-variable trick —
but templating cannot iterate, forcing exactly one group per device
and group-scoped secret paths. Kept as a fallback simplification.
- **SPIFFE/SPIRE** — workload-level (not device-level) identity would
let each deployment's process authenticate as itself, shrinking the
blast radius below anything device-scoped and making grants
O(deployments). Deferred: SPIRE server + per-device agents, weak
node attestation on TPM-less hardware, and no OpenBao SPIFFE auth
method yet. The group/policy layer chosen here survives that
migration — only the auth mount changes.
## Resolved during review
- ~~Re-login signal on membership change~~ — unnecessary; TTL expiry
is the sync.
- ~~Where membership lives~~ — Zitadel (roles-as-groups interim);
Device CR fields would create a second authority.
- Vocabulary: the staging fleet's device *label* named `group` must be
renamed or clearly distinguished — labels are placement vocabulary
and device-writable; the authorization group is admin-only.
## Open items for implementation
1. Confirm in staging that a disabled entity blocks an in-flight
batch token at request time (fact 4 is documented; prove it e2e).
2. TTL default 1 h — revisit with measured login load.
3. Per-cell sizing guidance once identity-store memory is measured on
OpenBao (Vault-lineage numbers above are the planning basis).