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.
9.6 KiB
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. 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 (entity-by-policy) shipped first and is correct at small scale, but two concerns drove a re-design:
- 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
NodeRestrictionadmission plugin; we had no equivalent, and no operator-facing way to say "this deployment may only ever be seen by the lab fleet." - 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)
- 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,
GroupAliasesnever 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.goand OpenBao's forkbuiltin/credential/jwt/path_login.go. - 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.
- Batch tokens carry identity. OpenBao marshals
EntityIDinto 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.) - Entity disable is the per-device kill switch.
identity/entitydisabled=true: "associated tokens cannot be used, but are not revoked" — request-time blocking, no device cooperation, works for batch tokens. - Identity store ceiling. Entities pack into 256 storage
entries (hard cap ≈ 0.5–1.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. - 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.
- 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, whilegroups_claimrequires a string array → flattening Action needed until first-class groups (epic zitadel#9702, in flight, machine users included) ship a documented array claim. - 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, minutes–hours 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
groupmust be renamed or clearly distinguished — labels are placement vocabulary and device-writable; the authorization group is admin-only.
Open items for implementation
- Confirm in staging that a disabled entity blocks an in-flight batch token at request time (fact 4 is documented; prove it e2e).
- TTL default 1 h — revisit with measured login load.
- Per-cell sizing guidance once identity-store memory is measured on OpenBao (Vault-lineage numbers above are the planning basis).