Authorize tenant-engine lifecycle actions (FLEX-WP-0010)

Extend the tenant-engine write-API policy package with tenant.update,
tenant.retire, and tenant.reactivate, the three actions TEN-WP-0005
introduced. Until now every lifecycle mutation resolved to deny
unknown_action, blocking TEN-WP-0005-T05 production rollout.

- policy_package.md: three actions in valid_actions and frontmatter;
  CARING capabilities extended with EditAny/Archive/Restore
- protected_system_manifest.yaml, subject_manifest.yaml, and the rebuilt
  registry_snapshot.json carry all seven actions
- docs/tenant-engine-action-vocabulary.md documents the HTTP surfaces
- 8 new fixture pairs and 5 new Rego tests, including action-drift
  guards (tenant.retired, tenant_update -> unknown_action)

T02 decision, recorded in policy_package.md: tenant.retire does NOT get
stricter authorization yet. With one service subject and no assurance
claim in the CheckRequest there is nothing stricter to check, and a
condition the sole caller always satisfies would falsely read as
separate control. Retirement is reversible and non-destructive. Revisit
on KEY-WP-0005 assurance claims or a second operator subject.

Verified: 11/11 Rego tests, 16/16 fixtures, go test ./... green, gofmt
and go vet clean. End-to-end against a live serve with a real
tenant-engine through the unmodified FlexAuthWriteAuthorizer: update
6176c39c2f4d7b15, retire 8a801b8ee8455080, reactivate c64cf3713cecd970
all allow; unregistered actor denied unknown_subject
(59d3e99c6416be89, 403 write_denied).

Handoff revision for TEN-WP-0005-T05: package
tenant-engine.write-api.mutate v1 (ready, caring-0.4.0-rc2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-10 20:52:10 +02:00
parent 4e76b59098
commit 8e127e50f7
11 changed files with 433 additions and 18 deletions

View file

@ -16,7 +16,8 @@
| workplan | FLEX-WP-0006 | finished | — | workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md | | 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-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-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-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-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 | | 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-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-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-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-T01 | done | — | 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-T02 | done | — | 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-T03 | done | — | 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-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 |

View file

@ -12,8 +12,19 @@ repos, not independently invented.
| `tenant.role.grant` | `POST /tenants/{id}/roles/grant` | `role-grant` | `allow`, `deny` | | `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.role.revoke` | `POST /tenants/{id}/roles/revoke` | `role-grant` | `allow`, `deny` |
| `tenant.plan.assign` | `POST /tenants/{id}/plan` | `plan-assignment` | `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 its `FlexAuthCheckClient` (`TEN-WP-0003`) never sends anything else, and
`tenant-engine`'s reads (`/roles`, `/roles/live`) are never gated through `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 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-engine`'s capability roles (`PLTF`/`IAM`/`VEN`/`CUS`, ADR-0014) are
**tenant** state — what a tenant is allowed to do on the platform. This **tenant** state — what a tenant is allowed to do on the platform. This
vocabulary governs a different question: which **operator/service 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 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. 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 See `examples/tenant-engine/policy_package.md`'s Rules section for how the

View file

@ -8,12 +8,12 @@ protected-system consumer, gating its own write API
| File | Purpose | | 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 | | `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_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 | | `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` | | `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 **No `resource_manifest.yaml`** — unlike ops-warden's fixed SSH-certificate
inventory, `tenant-engine`'s resources (tenants) are created dynamically. 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 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 ## Related
- `docs/tenant-engine-resource-namespace.md` - `docs/tenant-engine-resource-namespace.md`

View file

@ -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": {}
}

View file

@ -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": {}
}

View file

@ -47,6 +47,102 @@
}, },
"expect": {"effect": "allow", "reason": "write_api_policy_matched"} "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", "id": "fixture:tenant-engine-unknown-subject-deny",
"request": { "request": {

View file

@ -10,6 +10,9 @@ actions:
- tenant.role.grant - tenant.role.grant
- tenant.role.revoke - tenant.role.revoke
- tenant.plan.assign - tenant.plan.assign
- tenant.update
- tenant.retire
- tenant.reactivate
owner: team:platform-security owner: team:platform-security
fixtures: fixtures:
- policy_fixtures.yaml - policy_fixtures.yaml
@ -33,6 +36,9 @@ caring:
- Grant - Grant
- Revoke - Revoke
- Bind - Bind
- EditAny
- Archive
- Restore
- Audit - Audit
exposure_modes: exposure_modes:
- Metadata - 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 `tenant-engine`'s live-lookup endpoint (`FLEX-WP-0008-T03`); conflating the
two would authorize the wrong thing. 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 ## Rules
```rego ```rego
@ -73,6 +126,9 @@ valid_actions := {
"tenant.role.grant", "tenant.role.grant",
"tenant.role.revoke", "tenant.role.revoke",
"tenant.plan.assign", "tenant.plan.assign",
"tenant.update",
"tenant.retire",
"tenant.reactivate",
} }
known_operators := {"tenant-engine"} 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 { test_unknown_subject_denied if {
write_api.decision.reason == "unknown_subject" with input as { write_api.decision.reason == "unknown_subject" with input as {
"subject": {"id": "some-other-service", "type": "service"}, "subject": {"id": "some-other-service", "type": "service"},

View file

@ -70,6 +70,41 @@ actions:
- Metadata - Metadata
metadata: metadata:
required_context: [] 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_profiles:
- caring-0.4.0-rc2 - caring-0.4.0-rc2
metadata: metadata:

View file

@ -109,6 +109,59 @@
"metadata": { "metadata": {
"required_context": [] "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_profiles": [
@ -141,7 +194,7 @@
], ],
"tenant": "tenant:platform", "tenant": "tenant:platform",
"metadata": { "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."
} }
} }
], ],

View file

@ -14,9 +14,11 @@ subjects:
tenant: tenant:platform tenant: tenant:platform
metadata: metadata:
description: >- description: >-
tenant-engine's own service identity, used for the tenant.create / tenant-engine's own service identity, used for the seven write-API
tenant.role.grant / tenant.role.revoke / tenant.plan.assign actions actions it sends to POST /v1/check
it sends to POST /v1/check (authz.FlexAuthWriteAuthorizer). (authz.FlexAuthWriteAuthorizer): tenant.create, tenant.role.grant,
tenant.role.revoke, tenant.plan.assign, and the lifecycle actions
tenant.update, tenant.retire, tenant.reactivate.
groups: groups:
- id: group:tenant-engine-writers - id: group:tenant-engine-writers
display_name: tenant-engine Write API Callers display_name: tenant-engine Write API Callers

View file

@ -4,7 +4,7 @@ type: workplan
title: "Authorize tenant-engine lifecycle actions" title: "Authorize tenant-engine lifecycle actions"
domain: infotech domain: infotech
repo: flex-auth repo: flex-auth
status: ready status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: P1 planning_priority: P1
@ -16,6 +16,7 @@ related_workplans:
- USER-WP-0021 - USER-WP-0021
created: "2026-08-10" created: "2026-08-10"
updated: "2026-08-10" updated: "2026-08-10"
state_hub_workstream_id: "c159fe8b-d35b-4a74-8a15-c1263f7f6392"
--- ---
# FLEX-WP-0010 - Authorize tenant-engine lifecycle actions # FLEX-WP-0010 - Authorize tenant-engine lifecycle actions
@ -68,8 +69,9 @@ yet, and here is why".
```task ```task
id: FLEX-WP-0010-T01 id: FLEX-WP-0010-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "e92e45b9-724a-4fbd-8302-75558cf4b6c1"
``` ```
Add `tenant.update`, `tenant.retire`, and `tenant.reactivate` to 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 Done when `valid_actions` carries all seven actions and the vocabulary doc
and subject manifest no longer describe only four. 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 ## T02 - Decide whether retirement warrants stricter authorization
```task ```task
id: FLEX-WP-0010-T02 id: FLEX-WP-0010-T02
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "5aa9e104-a4b3-40cd-b5ad-4ff56421ce69"
``` ```
`tenant.retire` is the highest-consequence action in the set: it suspends a `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 Done when the decision and its reasoning are recorded in
`policy_package.md`, and implemented if the answer is "yes, stricter". `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 ## T03 - Fixtures and verification
```task ```task
id: FLEX-WP-0010-T03 id: FLEX-WP-0010-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "59856c87-a1b4-4fce-b554-9b13181bb8fd"
``` ```
Add `allow`/`deny` fixture pairs to 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 Rego engine over real HTTP, `go test ./...` is green across the repo, and
`gofmt`/`go vet` are clean. `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 ## T04 - Closure and handoff
```task ```task
id: FLEX-WP-0010-T04 id: FLEX-WP-0010-T04
status: todo status: done
priority: low priority: low
state_hub_task_id: "04141e54-a250-424e-8dfd-4148875e67da"
``` ```
Confirm T01T03. Run `statehub fix-consistency`. Notify `tenant-engine` that Confirm T01T03. Run `statehub fix-consistency`. Notify `tenant-engine` that
`TEN-WP-0005-T05` is unblocked, naming the policy revision — T05's own `TEN-WP-0005-T05` is unblocked, naming the policy revision — T05's own
done-criteria require the handoff to name the authorization policy revision done-criteria require the handoff to name the authorization policy revision
alongside the immutable image and API version. alongside the immutable image and API version.
Done 2026-08-10: T01T03 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.