diff --git a/workplans/ZONE-WP-0001-security-zone-model.md b/workplans/ZONE-WP-0001-security-zone-model.md index 36ed1c4..9f41762 100644 --- a/workplans/ZONE-WP-0001-security-zone-model.md +++ b/workplans/ZONE-WP-0001-security-zone-model.md @@ -58,6 +58,15 @@ compiled registry snapshot built by decision point by **compilation**. A synchronous zone lookup in the decision path would be a regression dressed as an architecture. +> **flex-auth reviewed this workplan on 2026-08-19** as the PDP that would have +> to live with the result, and amended T01, T03, T04 and T05 in place. The three +> substantive changes: the "only PDP" invariant needs to bind *authority* and not +> only latency (T01); membership compiles to the registry but **stance belongs in +> the policy package**, and fail-open is not expressible by a PDP at all (T03); +> and flex-auth has **no reload path**, which decides how a time-boxed exception +> can possibly expire (T04). Membership itself needs **no registry schema +> change** — but do not land it on the existing `trust_zone` field (T05). + **`warden plan`** (ops-warden WP-0029) — already returns `autonomous`/`founder_required`/`unroutable` from properties of the thing, with the `reasons` that produced the verdict. Zone-aware enforcement extends that @@ -82,6 +91,27 @@ the only PDP and consumes compiled membership. If either disagrees, the model moves and this repo is archived rather than kept for its own sake. Record the outcome either way. +**flex-auth answered 2026-08-19 (amended by flex-auth).** Confirmed: zone-engine +owns zone identity, membership and exception lifecycle; flex-auth stays the only +PDP. But the invariant as written in `GOAL.md` guards the wrong property. *"Never +a synchronous lookup in a decision path"* is a **latency** guarantee. It does not +guarantee flex-auth remains the only decision point, because compiled data that +determines an outcome is still deciding — it just decided earlier. Under the +invariant as written, zone-engine could compile `enforced: false` for a lane and +flip `warden sign` from deny to allow without a flex-auth policy change, and +still be literally compliant. + +The invariant flex-auth confirms is the tighter one: + +> zone-engine is the authority over zone **identity and membership**. The +> **effect** of a zone on any decision flex-auth renders is expressed in a +> flex-auth policy package — versioned, fixtured, tested, and stamped into the +> decision record. Membership is data and is compiled. Stance is a rule and is +> policy. + +Rationale is in T03. Please carry the tightened wording into `GOAL.md`; the +current wording is not wrong, it is just not load-bearing. + ```task id: ZONE-WP-0001-T02 status: todo @@ -124,6 +154,48 @@ Controls in scope at minimum: the flex-auth pre-sign gate (`policy.enabled`, `fail_closed`), the agent read-boundary on `risk: high` lanes (ops-warden `ADR-0004`), and the `warden plan` escalation verdicts. +**Amended by flex-auth 2026-08-19 — split membership from stance.** ops-warden's +option (c) ("zones as policy-package data") was rejected wholesale because the +model must govern two controls flex-auth does not evaluate. flex-auth agrees with +the premise and disagrees with the conclusion: (c) was rejected at the wrong +granularity. *Where a stance is evaluated belongs with the control's enforcement +point*, and there are three enforcement points here, not one: + +| Control | Enforcement point | Where its per-zone stance belongs | +| --- | --- | --- | +| flex-auth pre-sign gate | flex-auth (PDP) + `warden sign` (PEP) | **flex-auth policy package** | +| Agent read-boundary (`ADR-0004`) | ops-warden CLI | ops-warden — flex-auth never sees it | +| `warden plan` escalation verdicts | ops-warden CLI | ops-warden — flex-auth never sees it | + +So (b) is right for two of three controls and wrong for the one that is +flex-auth's. Concretely, for the pre-sign gate: + +- **Membership → compiled registry.** Which zone an actor/lane is in is data, + varies per resource, changes as the estate changes, and is already produced by + `build_flex_auth_registry.py`. Compile it. +- **Stance → policy package.** "In zone X the gate is advisory" is a rule about + outcomes. It belongs in the artifact that already carries fixtures, rego tests, + `Validate()`, a version, and a status. + +The concrete defect in compiling stance: every flex-auth decision envelope stamps +`matched_policy_version`, `policy_package` and `policy_status`, and the decision +id is derived from policy metadata plus request +(`internal/decision/engine.go:358`). Registry content is **not** in that +provenance. If zone stance lives in the registry, a stance change alters the +verdict while the decision record still reports the same policy version — two +decisions, same version, same request, different effect, and nothing in the audit +trail explains why. That breaks flex-auth's reproducibility contract for exactly +the control this workplan exists to make safe. + +**Second correction: flex-auth cannot express fail-open.** A PDP returns an +effect. "Fail-open" describes what the caller does when flex-auth is +*unreachable* — that is a property of ops-warden's client (`fail_closed` in +`warden.yaml`), and no zone data compiled into flex-auth can influence it. Model +the failure-mode axis as **PEP-side**, owned by each consumer's declaration, not +as something the PDP carries. `enforced`/`advisory`/`exempt` *is* expressible by +flex-auth (as effect plus an advisory annotation in the envelope); fail-open is +not, and a model that implies otherwise will be built and then not work. + ```task id: ZONE-WP-0001-T04 status: todo @@ -144,6 +216,31 @@ happens at expiry — auto-revert, or block until renewed. If T04 concludes the lifecycle can live as reviewed declarations in git, say so plainly and drop the runtime. That is a valid and cheaper outcome. +**Hard constraint from flex-auth 2026-08-19 — the PDP has no reload path.** +`cmd/flex-auth/main.go:447` calls `registry.LoadFile` once at process start. +There is no watcher, no SIGHUP, no reload endpoint. Both the registry snapshot +*and* the policy package are baked into the container image and the production +pins are digest-pinned (`values/ops-warden.yaml`, +`sha256:138aa347…`). Changing either means: rebuild image → new digest → helm +upgrade → pod restart. + +This is the input T04 most needs, and it kills one design outright: + +- **An exception compiled as inert registry data cannot have an enforced + expiry.** It expires when a human rebuilds and redeploys. That is expiry by + intention — precisely what this task says it must not be. +- **An exception carrying its own `not_after` timestamp, evaluated against + decision time in rego, does have an enforced expiry.** OPA has a clock + (`time.now_ns()`); the grant costs a redeploy but the *lapse* is automatic and + needs nobody. The zone widens by deploy and narrows by itself, which is the + correct asymmetry. + +That is a third argument for stance-in-policy independent of the audit one in +T03, and it also bears on whether this repo earns a runtime: if expiry is +evaluated at decision time from a compiled timestamp, the lifecycle needs a +*record and a review*, not a service. flex-auth's read is that this points at the +cheaper outcome, but it is T04's call. + ```task id: ZONE-WP-0001-T05 status: todo @@ -156,6 +253,37 @@ with evidence, `reviewed`, and `review_due`; carry over *accuracy, not altitude*. Include how a zone assignment changes and how that change is observed — a zone that can be quietly widened is not a boundary. +**Two carrier facts from flex-auth 2026-08-19 (no schema change needed).** +Compiled zone membership is expressible in flex-auth's registry format **today**, +with zero Go changes. Every registry entity carries `metadata`; `api.Resource` +additionally carries `labels`, `owner`, `attributes` and `trust_zone`, and +`internal/decision/engine.go:275` flattens all of them into +`input.resource.attributes` before evaluation. `subject.metadata` and +`subject.claims` are flattened the same way (`engine.go:257`). A compiler writing +`"security_zone": "..."` into a resource's `attributes` makes it readable from +rego as `input.resource.attributes.security_zone`. Nothing to build. + +Two things to decide anyway, and both belong in this task: + +1. **Do not reuse `trust_zone` — this collision has already happened inside the + PDP.** `api.Resource.TrustZone` is a first-class field, is surfaced into rego + input, and `ops-warden/scripts/build_flex_auth_registry.py:77` sets it to the + hardcoded constant `"platform"` on every ssh-certificate resource. No policy + package reads it. It is a dormant, plausibly-named, semantically-empty field + sitting exactly where a reader would expect zone membership to go. `SCOPE.md` + warns that "zone" is overloaded and names network segmentation as the hazard; + the actual live collision is this one. Name the new concept `security_zone` + explicitly, or deliberately repurpose `trust_zone` and say so loudly — but do + not let both exist meaning different things. + +2. **Say whether membership rides the subject or the resource.** In the + ops-warden registry an actor appears as *both*: as a subject + (`agt-state-hub-bridge`) and as a per-actor resource + (`ssh-cert:actor/agt-state-hub-bridge`). If T02 concludes a zone is a property + of the actor, the compiler must be told which record carries it — or write + both and make that the contract. Leaving it implicit produces two compilers + that disagree. This is a compiler contract, not a schema change. + ```task id: ZONE-WP-0001-T06 status: todo