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.
3.6 KiB
ADR 025-2: Entity-by-Policy Scoping (as built)
Initial Author: Jean-Gabriel Gill-Couture
Initial Date: 2026-06-11
Status
Retired. First implementation shipped on
feat/fleet-device-secret-access, then superseded by the group-based
design (main ADR-025) after
the scale analysis in
025-3: per-device entity
writes are O(targeted devices) per deployment change, which does not
hold at fleet scale. The identity layer built alongside it
(harmony_zitadel_jwt, the JWT-bearer auth rung, refresh_auth())
carries over unchanged.
What changed vs 025-1
025-1 carried per-deployment
membership inside the token: Zitadel user metadata → token Action →
JWT deployments claim → OpenBao groups_claim → external groups.
Implementation replaced that whole chain with one server-side write:
A device's secret access is its OpenBao identity entity's policy
list. The operator sets the entity's policies to exactly
deployment-<name> for each deployment currently scheduled on the
device. OpenBao resolves identity policies at request time, so a
grant or revocation takes effect on the device's very next read — the
device logs in once and keeps its token; no re-auth, no thundering herd
on bulk reassignment. Proven by the e2e test
entity_policy_grants_existing_openbao_token_access
(fleet/harmony-fleet-e2e/tests/openbao_policy.rs).
Why the claim chain lost
- Tokens snapshot, entities don't. Claim-carried scope is frozen at mint time; every membership change required invalidating two cached tokens (Zitadel + Bao) per device. Entity policies change server-side with zero device participation — strictly better for revocation and for offline devices.
- Less machinery. Dropped: the Zitadel token Action (server-side JS
in the security path),
groups_claim+ external groups, the agent'scached_scope()and before-reconcile refresh-check, and the operator's Zitadel-metadata write scope. The operator now writes only OpenBao. - One source of truth. Membership derives from the operator's view
of the
DeploymentCRs in its reconcile loop, not from a second copy in Zitadel user metadata.
Shipped shape
| Piece | Home |
|---|---|
DeviceSecretAccess trait (devices ↔ deployments, backend-agnostic) + in-memory test impl |
harmony/src/modules/fleet/secret_access.rs |
OpenBaoDeviceSecretAccess (entity resolution via JWT-mount alias, policy-list writes) |
harmony/src/modules/openbao/identity.rs |
Operator wiring: sync_secret_access after every selector/device change |
fleet/harmony-fleet-operator/src/fleet_aggregator.rs |
| JWT-bearer minter (RFC 7523), extracted to a leaf crate to break a dependency cycle | harmony_zitadel_jwt (re-exported by harmony_zitadel_auth) |
JWT-bearer rung on the auth ladder + refresh_auth() |
harmony_secret/src/store/openbao.rs |
Entity resolution subtlety: OpenBao auto-provisions an entity (random
name) + alias (name = user_claim, i.e. the device id) on first JWT
login. The operator therefore resolves device → entity via the
alias, creating entity + alias itself when the device has never
logged in, so access can be granted ahead of first login.
What this iteration deliberately does not solve
Scheduling — and therefore secret access — is gated only by the deployment's label selector over device-reported labels. A device that can choose its own labels can attract deployments and their secrets. That gap is the subject of 025-3.