Files
harmony/docs/adr/025-device-secret-access/025-2-entity-by-policy.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

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's cached_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 Deployment CRs 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.