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.
75 lines
3.6 KiB
Markdown
75 lines
3.6 KiB
Markdown
# 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](../025-fleet-device-secret-access.md)) after
|
|
the scale analysis in
|
|
[025-3](./025-3-groups-as-security-boundary.md): 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](./025-1-jwt-deployments-claim.md) 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](./025-3-groups-as-security-boundary.md).
|