diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 86ae657..f097acc 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,7 +16,8 @@ | workplan | FLEX-WP-0006 | finished | — | workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md | | workplan | FLEX-WP-0007 | finished | — | workplans/FLEX-WP-0007-ops-warden-policy-gate-production-deployment.md | | workplan | FLEX-WP-0008 | finished | — | workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md | -| workplan | FLEX-WP-0009 | ready | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| workplan | FLEX-WP-0009 | active | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| workplan | FLEX-WP-0010 | finished | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | | task | FLEX-WP-0001-T001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | task | FLEX-WP-0001-T002 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | task | FLEX-WP-0001-T003 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | @@ -61,7 +62,11 @@ | task | FLEX-WP-0008-T02 | done | — | workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md | | task | FLEX-WP-0008-T03 | done | — | workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md | | task | FLEX-WP-0008-T04 | done | — | workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md | -| task | FLEX-WP-0009-T01 | todo | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | -| task | FLEX-WP-0009-T02 | todo | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | -| task | FLEX-WP-0009-T03 | todo | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | -| task | FLEX-WP-0009-T04 | todo | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| task | FLEX-WP-0009-T01 | done | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| task | FLEX-WP-0009-T02 | done | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| task | FLEX-WP-0009-T03 | done | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| task | FLEX-WP-0009-T04 | progress | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | +| task | FLEX-WP-0010-T01 | done | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | +| task | FLEX-WP-0010-T02 | done | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | +| task | FLEX-WP-0010-T03 | done | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | +| task | FLEX-WP-0010-T04 | done | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | diff --git a/docs/tenant-engine-action-vocabulary.md b/docs/tenant-engine-action-vocabulary.md index 2882f8e..0b35bea 100644 --- a/docs/tenant-engine-action-vocabulary.md +++ b/docs/tenant-engine-action-vocabulary.md @@ -12,8 +12,19 @@ repos, not independently invented. | `tenant.role.grant` | `POST /tenants/{id}/roles/grant` | `role-grant` | `allow`, `deny` | | `tenant.role.revoke` | `POST /tenants/{id}/roles/revoke` | `role-grant` | `allow`, `deny` | | `tenant.plan.assign` | `POST /tenants/{id}/plan` | `plan-assignment` | `allow`, `deny` | +| `tenant.update` | `PATCH /tenants/{id}` | `tenant` | `allow`, `deny` | +| `tenant.retire` | `POST /tenants/{id}/retire` | `tenant` | `allow`, `deny` | +| `tenant.reactivate` | `POST /tenants/{id}/reactivate` | `tenant` | `allow`, `deny` | -These are the only four actions `tenant-engine` sends to `POST /v1/check` — +The last three are the tenant lifecycle actions added by `FLEX-WP-0010` for +`TEN-WP-0005`. They are three actions rather than one `tenant.write` because +tenant-engine separated them so policy *can* permit renaming a tenant without +thereby permitting retiring it. Under the current single-operator model all +three resolve identically — the separation is a seam for later, and the +reasoning for not yet treating `tenant.retire` more strictly is recorded in +`examples/tenant-engine/policy_package.md`. + +These are the only seven actions `tenant-engine` sends to `POST /v1/check` — its `FlexAuthCheckClient` (`TEN-WP-0003`) never sends anything else, and `tenant-engine`'s reads (`/roles`, `/roles/live`) are never gated through flex-auth at all; they're `tenant-engine`'s own cache-read and live-lookup @@ -24,7 +35,10 @@ endpoints, consumed *by* `key-cape` and `flex-auth` respectively. `tenant-engine`'s capability roles (`PLTF`/`IAM`/`VEN`/`CUS`, ADR-0014) are **tenant** state — what a tenant is allowed to do on the platform. This vocabulary governs a different question: which **operator/service -subjects** are allowed to call `tenant-engine`'s admin API at all. The two +subjects** are allowed to call `tenant-engine`'s admin API at all. The +lifecycle actions do not change that boundary: `tenant.retire` asks whether +*this caller* may retire a tenant, not what a *retired tenant* may still do — +the latter is tenant state that tenant-engine enforces itself. The two must not be conflated — a policy package that checked a *caller's* action against a *tenant's* capability roles would be checking the wrong thing. See `examples/tenant-engine/policy_package.md`'s Rules section for how the diff --git a/examples/tenant-engine/README.md b/examples/tenant-engine/README.md index db0a493..42e4335 100644 --- a/examples/tenant-engine/README.md +++ b/examples/tenant-engine/README.md @@ -8,12 +8,12 @@ protected-system consumer, gating its own write API | File | Purpose | | --- | --- | -| `protected_system_manifest.yaml` | Resource types (`tenant`, `role-grant`, `plan-assignment`) and actions (`tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, `tenant.plan.assign`) | +| `protected_system_manifest.yaml` | Resource types (`tenant`, `role-grant`, `plan-assignment`) and the seven actions: `tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, `tenant.plan.assign`, plus the `FLEX-WP-0010` lifecycle actions `tenant.update`, `tenant.retire`, `tenant.reactivate` | | `subject_manifest.yaml` | The one registered caller: `tenant-engine`'s own service identity | | `policy_package.md` | Rego rules + embedded tests gating the write API | | `policy_fixtures.yaml` | Allow/deny request/decision pairs, referenced by `policy_package.md`'s frontmatter | | `registry_snapshot.json` | Merged `systems`/`subjects`/`groups` snapshot assembled from the two manifests above, loadable by `flex-auth serve`/`check`/`load-registry` | -| `check_request_allow_create.json`, `check_request_deny_unknown_subject.json` | Standalone example requests for `flex-auth check` | +| `check_request_allow_create.json`, `check_request_deny_unknown_subject.json`, `check_request_allow_retire.json`, `check_request_deny_misspelled_lifecycle.json` | Standalone example requests for `flex-auth check` | **No `resource_manifest.yaml`** — unlike ops-warden's fixed SSH-certificate inventory, `tenant-engine`'s resources (tenants) are created dynamically. @@ -49,6 +49,31 @@ bin/flex-auth serve -addr 127.0.0.1:9098 \ TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098 make run ``` +## Verified — lifecycle actions (FLEX-WP-0010-T03, 2026-08-10) + +`test-policy` reports **11/11 Rego tests and 16/16 fixtures passing**, the +registry loads, and `go test ./...` / `gofmt` / `go vet` are clean. + +End-to-end over real HTTP: a live `flex-auth serve` on `127.0.0.1:9098` +loaded with this registry and policy, and a real `tenant-engine` +(`TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098`) driven through its +unmodified `FlexAuthWriteAuthorizer` with `Idempotency-Key` and an `If-Match` +echoed from a prior `GET`: + +| Call | tenant-engine | flex-auth decision | +| --- | --- | --- | +| `POST /tenants` | 201 | `decision:174dc9ecb03ed9e5` allow `write_api_policy_matched` | +| `PATCH /tenants/t-e2e-1` | 200 `active` | `decision:6176c39c2f4d7b15` allow `write_api_policy_matched` | +| `POST /tenants/t-e2e-1/retire` | 200 `retired` | `decision:8a801b8ee8455080` allow `write_api_policy_matched` | +| `POST /tenants/t-e2e-1/reactivate` | 200 `active` | `decision:c64cf3713cecd970` allow `write_api_policy_matched` | +| `POST .../retire` as `actor: ops` | 403 `write_denied` | `decision:59d3e99c6416be89` deny `unknown_subject` | + +The misspelled-action guard (`tenant.retired` → deny `unknown_action`) is +covered by fixture and by +`check -request check_request_deny_misspelled_lifecycle.json`; it cannot be +driven through the client, which only ever emits the seven registered strings +— which is the property the guard exists to protect. + ## Related - `docs/tenant-engine-resource-namespace.md` diff --git a/examples/tenant-engine/check_request_allow_retire.json b/examples/tenant-engine/check_request_allow_retire.json new file mode 100644 index 0000000..af7fca8 --- /dev/null +++ b/examples/tenant-engine/check_request_allow_retire.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-retire-t1", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "tenant-engine", + "type": "service" + }, + "action": "tenant.retire", + "resource": { + "id": "t-1", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/check_request_deny_misspelled_lifecycle.json b/examples/tenant-engine/check_request_deny_misspelled_lifecycle.json new file mode 100644 index 0000000..988fa63 --- /dev/null +++ b/examples/tenant-engine/check_request_deny_misspelled_lifecycle.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-retired-t1", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "tenant-engine", + "type": "service" + }, + "action": "tenant.retired", + "resource": { + "id": "t-1", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/policy_fixtures.yaml b/examples/tenant-engine/policy_fixtures.yaml index 37b49c4..8cc18ac 100644 --- a/examples/tenant-engine/policy_fixtures.yaml +++ b/examples/tenant-engine/policy_fixtures.yaml @@ -47,6 +47,102 @@ }, "expect": {"effect": "allow", "reason": "write_api_policy_matched"} }, + { + "id": "fixture:tenant-engine-update-allow", + "request": { + "id": "check:tenant-engine-update-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.update", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-retire-allow", + "request": { + "id": "check:tenant-engine-retire-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.retire", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-reactivate-allow", + "request": { + "id": "check:tenant-engine-reactivate-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.reactivate", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-update-unknown-subject-deny", + "request": { + "id": "check:tenant-engine-update-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "some-other-service", "type": "service"}, + "action": "tenant.update", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_subject"} + }, + { + "id": "fixture:tenant-engine-retire-unknown-subject-deny", + "request": { + "id": "check:tenant-engine-retire-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "some-other-service", "type": "service"}, + "action": "tenant.retire", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_subject"} + }, + { + "id": "fixture:tenant-engine-reactivate-unknown-subject-deny", + "request": { + "id": "check:tenant-engine-reactivate-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "some-other-service", "type": "service"}, + "action": "tenant.reactivate", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_subject"} + }, + { + "id": "fixture:tenant-engine-misspelled-lifecycle-action-deny", + "request": { + "id": "check:tenant-engine-retired-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.retired", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_action"} + }, + { + "id": "fixture:tenant-engine-lifecycle-underscore-action-deny", + "request": { + "id": "check:tenant-engine-underscore-t1", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant_update", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_action"} + }, { "id": "fixture:tenant-engine-unknown-subject-deny", "request": { diff --git a/examples/tenant-engine/policy_package.md b/examples/tenant-engine/policy_package.md index 0516467..99e10c1 100644 --- a/examples/tenant-engine/policy_package.md +++ b/examples/tenant-engine/policy_package.md @@ -10,6 +10,9 @@ actions: - tenant.role.grant - tenant.role.revoke - tenant.plan.assign + - tenant.update + - tenant.retire + - tenant.reactivate owner: team:platform-security fixtures: - policy_fixtures.yaml @@ -33,6 +36,9 @@ caring: - Grant - Revoke - Bind + - EditAny + - Archive + - Restore - Audit exposure_modes: - Metadata @@ -61,6 +67,53 @@ tenant state a *different* protected system's policy might consult via `tenant-engine`'s live-lookup endpoint (`FLEX-WP-0008-T03`); conflating the two would authorize the wrong thing. +## Lifecycle actions (FLEX-WP-0010) + +`tenant.update`, `tenant.retire`, and `tenant.reactivate` gate +`tenant-engine`'s lifecycle endpoints (`PATCH /tenants/{id}`, +`POST /tenants/{id}/retire`, `POST /tenants/{id}/reactivate`). The action +strings are the exact values `authz.FlexAuthWriteAuthorizer` sends; they are +coordinated between the two repos, not independently chosen here. + +They are three actions rather than one `tenant.write` because tenant-engine +deliberately separated them so policy *can* one day permit renaming a tenant +without permitting retiring it. Under the current model that separation is a +seam, not a difference — see the decision below. + +### Decision: `tenant.retire` does not require stricter authorization yet + +**Decision (FLEX-WP-0010-T02, 2026-08-10): no.** `tenant.retire` is +authorized by the same rule as the other six actions. + +Reasoning: + +1. **There is nothing stricter to check.** The subject set is exactly one + service identity (`tenant-engine`), there is no second operator to + distinguish from, and requests carry no `assurance` claim — the + `CheckRequest` shape from FLEX-WP-0008 has no field that could + differentiate retirement from an update. A "stricter" rule today could + only be stricter in name. +2. **A rule that cannot fail is worse than no rule.** Adding a condition + that the single known caller always satisfies would read, to a later + reviewer, as though retirement were separately controlled when it is + not. That is a false assurance in a policy package whose job is to be + inspectable. +3. **The blast radius is recoverable.** Retirement is reversible by design + via `tenant.reactivate`, and tenant-engine hard-deletes nothing. A + wrongly allowed retirement suspends new grants and plan changes until + reactivated; it does not destroy tenant state. +4. **The seam is already cut.** Because the three actions are distinct + strings in `valid_actions`, tightening `tenant.retire` later is an + additive change to this package — no consumer change, no request-shape + change, no coordination round with tenant-engine. + +**Revisit when** `KEY-WP-0005` gives callers an `assurance`-bearing identity, +**or** when a second operator subject is registered against +`system: "tenant-engine"` — whichever comes first. At that point the honest +options are an assurance floor on `tenant.retire` or a distinct +`group:tenant-engine-lifecycle` membership requirement; both are one rule in +`allowed` plus a `first_denial` branch. + ## Rules ```rego @@ -73,6 +126,9 @@ valid_actions := { "tenant.role.grant", "tenant.role.revoke", "tenant.plan.assign", + "tenant.update", + "tenant.retire", + "tenant.reactivate", } known_operators := {"tenant-engine"} @@ -131,6 +187,46 @@ test_role_grant_allowed if { } } +test_tenant_update_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.update", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + } +} + +test_tenant_retire_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.retire", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + } +} + +test_tenant_reactivate_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.reactivate", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + } +} + +test_misspelled_lifecycle_action_denied if { + write_api.decision.reason == "unknown_action" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.retired", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + } +} + +test_unknown_subject_retire_denied if { + write_api.decision.reason == "unknown_subject" with input as { + "subject": {"id": "some-other-service", "type": "service"}, + "action": "tenant.retire", + "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + } +} + test_unknown_subject_denied if { write_api.decision.reason == "unknown_subject" with input as { "subject": {"id": "some-other-service", "type": "service"}, diff --git a/examples/tenant-engine/protected_system_manifest.yaml b/examples/tenant-engine/protected_system_manifest.yaml index c528ed0..6e61a37 100644 --- a/examples/tenant-engine/protected_system_manifest.yaml +++ b/examples/tenant-engine/protected_system_manifest.yaml @@ -70,6 +70,41 @@ actions: - Metadata metadata: required_context: [] + - name: tenant.update + capabilities: + - EditAny + - Audit + planes: + - Identity + - Audit + exposure_modes: + - Metadata + metadata: + required_context: [] + - name: tenant.retire + capabilities: + - Archive + - Audit + planes: + - Identity + - Policy + - Audit + exposure_modes: + - Metadata + metadata: + required_context: [] + - name: tenant.reactivate + capabilities: + - Restore + - Audit + planes: + - Identity + - Policy + - Audit + exposure_modes: + - Metadata + metadata: + required_context: [] caring_profiles: - caring-0.4.0-rc2 metadata: diff --git a/examples/tenant-engine/registry_snapshot.json b/examples/tenant-engine/registry_snapshot.json index e4ce370..fb5033a 100644 --- a/examples/tenant-engine/registry_snapshot.json +++ b/examples/tenant-engine/registry_snapshot.json @@ -109,6 +109,59 @@ "metadata": { "required_context": [] } + }, + { + "name": "tenant.update", + "capabilities": [ + "EditAny", + "Audit" + ], + "planes": [ + "Identity", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } + }, + { + "name": "tenant.retire", + "capabilities": [ + "Archive", + "Audit" + ], + "planes": [ + "Identity", + "Policy", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } + }, + { + "name": "tenant.reactivate", + "capabilities": [ + "Restore", + "Audit" + ], + "planes": [ + "Identity", + "Policy", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } } ], "caring_profiles": [ @@ -141,7 +194,7 @@ ], "tenant": "tenant:platform", "metadata": { - "description": "tenant-engine's own service identity, used for the tenant.create / tenant.role.grant / tenant.role.revoke / tenant.plan.assign actions it sends to POST /v1/check (authz.FlexAuthWriteAuthorizer)." + "description": "tenant-engine's own service identity, used for the seven write-API actions it sends to POST /v1/check (authz.FlexAuthWriteAuthorizer): tenant.create, tenant.role.grant, tenant.role.revoke, tenant.plan.assign, and the lifecycle actions tenant.update, tenant.retire, tenant.reactivate." } } ], diff --git a/examples/tenant-engine/subject_manifest.yaml b/examples/tenant-engine/subject_manifest.yaml index 1b8b216..a6f997f 100644 --- a/examples/tenant-engine/subject_manifest.yaml +++ b/examples/tenant-engine/subject_manifest.yaml @@ -14,9 +14,11 @@ subjects: tenant: tenant:platform metadata: description: >- - tenant-engine's own service identity, used for the tenant.create / - tenant.role.grant / tenant.role.revoke / tenant.plan.assign actions - it sends to POST /v1/check (authz.FlexAuthWriteAuthorizer). + tenant-engine's own service identity, used for the seven write-API + actions it sends to POST /v1/check + (authz.FlexAuthWriteAuthorizer): tenant.create, tenant.role.grant, + tenant.role.revoke, tenant.plan.assign, and the lifecycle actions + tenant.update, tenant.retire, tenant.reactivate. groups: - id: group:tenant-engine-writers display_name: tenant-engine Write API Callers diff --git a/workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md b/workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md index b6102f7..330f0f1 100644 --- a/workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md +++ b/workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md @@ -4,7 +4,7 @@ type: workplan title: "Authorize tenant-engine lifecycle actions" domain: infotech repo: flex-auth -status: ready +status: finished owner: codex topic_slug: netkingdom planning_priority: P1 @@ -16,6 +16,7 @@ related_workplans: - USER-WP-0021 created: "2026-08-10" updated: "2026-08-10" +state_hub_workstream_id: "c159fe8b-d35b-4a74-8a15-c1263f7f6392" --- # FLEX-WP-0010 - Authorize tenant-engine lifecycle actions @@ -68,8 +69,9 @@ yet, and here is why". ```task id: FLEX-WP-0010-T01 -status: todo +status: done priority: high +state_hub_task_id: "e92e45b9-724a-4fbd-8302-75558cf4b6c1" ``` Add `tenant.update`, `tenant.retire`, and `tenant.reactivate` to @@ -93,12 +95,24 @@ Rebuild `examples/tenant-engine/registry_snapshot.json`. Done when `valid_actions` carries all seven actions and the vocabulary doc and subject manifest no longer describe only four. +Done 2026-08-10: all seven actions are in `valid_actions`, the frontmatter +`actions:` list, and the protected-system manifest. CARING capabilities were +extended with `EditAny` (update), `Archive` (retire), and `Restore` +(reactivate) — the existing `Create`/`Grant`/`Revoke`/`Bind` set had no +update or lifecycle shape, and `Archive`/`Restore` name the reversible pair +exactly as tenant-engine implements it. `registry_snapshot.json` was rebuilt +from the two manifests, the subject metadata now enumerates all seven, and +`docs/tenant-engine-action-vocabulary.md` carries the three HTTP surfaces +plus an explicit note that `tenant.retire` asks whether *this caller* may +retire a tenant, not what a *retired tenant* may do. + ## T02 - Decide whether retirement warrants stricter authorization ```task id: FLEX-WP-0010-T02 -status: todo +status: done priority: medium +state_hub_task_id: "5aa9e104-a4b3-40cd-b5ad-4ff56421ce69" ``` `tenant.retire` is the highest-consequence action in the set: it suspends a @@ -119,12 +133,25 @@ allowed retirement is recoverable, which is legitimate input to this decision. Done when the decision and its reasoning are recorded in `policy_package.md`, and implemented if the answer is "yes, stricter". +Done 2026-08-10: **no — `tenant.retire` is authorized by the same rule as the +other six.** Recorded with full reasoning under "Decision: `tenant.retire` +does not require stricter authorization yet" in +`examples/tenant-engine/policy_package.md`. The load-bearing reason is that +there is nothing stricter to check: one service subject, no second operator, +no `assurance` field in the `CheckRequest`. A condition the sole caller +always satisfies would read to a later reviewer as though retirement were +separately controlled when it is not — a false assurance in a package whose +job is inspectability. Retirement being reversible and non-destructive makes +that acceptable. Revisit when `KEY-WP-0005` supplies an assurance-bearing +identity, or when a second `tenant-engine` operator subject is registered. + ## T03 - Fixtures and verification ```task id: FLEX-WP-0010-T03 -status: todo +status: done priority: high +state_hub_task_id: "59856c87-a1b4-4fce-b554-9b13181bb8fd" ``` Add `allow`/`deny` fixture pairs to @@ -151,15 +178,47 @@ Done when all three lifecycle mutations return a real `allow` from the real Rego engine over real HTTP, `go test ./...` is green across the repo, and `gofmt`/`go vet` are clean. +Done 2026-08-10: eight fixture pairs added (allow for each of the three +actions, `unknown_subject` deny for each, and two action-drift denies — +`tenant.retired` and `tenant_update`, both `unknown_action`). Five embedded +Rego tests added. `test-policy` reports 11/11 tests and 16/16 fixtures +passing; `load-registry` loads clean; `check` returns the expected decisions +for the two new standalone request files. + +End-to-end against a live `flex-auth serve` (127.0.0.1:9098) with a real +`tenant-engine` on `TENANT_ENGINE_FLEX_AUTH_URL`, driven through the +unmodified `FlexAuthWriteAuthorizer` with `Idempotency-Key` and an `If-Match` +echoed from a prior `GET`: + +| Call | tenant-engine | flex-auth decision | +| --- | --- | --- | +| `POST /tenants` | 201 | `decision:174dc9ecb03ed9e5` allow | +| `PATCH /tenants/t-e2e-1` | 200 `active` | `decision:6176c39c2f4d7b15` allow | +| `POST .../retire` | 200 `retired` | `decision:8a801b8ee8455080` allow | +| `POST .../reactivate` | 200 `active` | `decision:c64cf3713cecd970` allow | +| `POST .../retire` as `actor: ops` | 403 `write_denied` | `decision:59d3e99c6416be89` deny `unknown_subject` | + +`go test ./...` green across all packages; `gofmt -l` empty; `go vet ./...` +clean. Evidence table mirrored into `examples/tenant-engine/README.md`. + ## T04 - Closure and handoff ```task id: FLEX-WP-0010-T04 -status: todo +status: done priority: low +state_hub_task_id: "04141e54-a250-424e-8dfd-4148875e67da" ``` Confirm T01–T03. Run `statehub fix-consistency`. Notify `tenant-engine` that `TEN-WP-0005-T05` is unblocked, naming the policy revision — T05's own done-criteria require the handoff to name the authorization policy revision alongside the immutable image and API version. + +Done 2026-08-10: T01–T03 confirmed. Policy revision for the handoff is +package `tenant-engine.write-api.mutate` **version `v1`**, status `ready`, +profile `caring-0.4.0-rc2`, now carrying all seven actions — the version +string is unchanged because the package is additive and `v1` remains the +activated revision; the identifying fact for TEN-WP-0005-T05 is that `v1` as +of this commit contains `tenant.update`, `tenant.retire`, and +`tenant.reactivate`. `TEN-WP-0005-T05` is unblocked.