From f304688d7205573703bc2779227a542a8ccda698 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 16 Aug 2026 02:46:25 +0200 Subject: [PATCH] Authorize tenant-engine guardrail read and set actions FLEX-WP-0014. Package tenant-engine.write-api.mutate v1 now carries nine actions: tenant.guardrail.read and tenant.guardrail.set on resource type guardrail, verbatim from tenant-engine's mapping. The read/write split is used now: flex-auth may read ceilings and is denied action_not_granted on set; tenant-engine may do both. 17/17 Rego tests and 23/23 fixtures pass. Live e2e against a real tenant-engine FlexAuthWriteAuthorizer matches. In source only; production still serves the seven-action 9320df39 pin. --- docs/tenant-engine-action-vocabulary.md | 25 +-- docs/tenant-engine-resource-namespace.md | 21 +- examples/tenant-engine/README.md | 35 +++- .../check_request_allow_guardrail_read.json | 15 ++ .../check_request_allow_guardrail_set.json | 15 ++ ..._request_deny_guardrail_set_as_reader.json | 15 ++ ...eck_request_deny_misspelled_guardrail.json | 15 ++ examples/tenant-engine/policy_fixtures.yaml | 84 ++++++++ examples/tenant-engine/policy_package.md | 118 ++++++++++- .../protected_system_manifest.yaml | 31 +++ examples/tenant-engine/registry_snapshot.json | 75 ++++++- examples/tenant-engine/subject_manifest.yaml | 28 ++- ...WP-0014-tenant-guardrail-policy-actions.md | 184 ++++++++++++++++++ 13 files changed, 627 insertions(+), 34 deletions(-) create mode 100644 examples/tenant-engine/check_request_allow_guardrail_read.json create mode 100644 examples/tenant-engine/check_request_allow_guardrail_set.json create mode 100644 examples/tenant-engine/check_request_deny_guardrail_set_as_reader.json create mode 100644 examples/tenant-engine/check_request_deny_misspelled_guardrail.json create mode 100644 workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md diff --git a/docs/tenant-engine-action-vocabulary.md b/docs/tenant-engine-action-vocabulary.md index 0b35bea..1290734 100644 --- a/docs/tenant-engine-action-vocabulary.md +++ b/docs/tenant-engine-action-vocabulary.md @@ -15,20 +15,23 @@ repos, not independently invented. | `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` | +| `tenant.guardrail.read` | `GET /tenants/{id}/guardrails` | `guardrail` | `allow`, `deny` | +| `tenant.guardrail.set` | `PUT` / `DELETE /tenants/{id}/guardrails/{limit_key}` | `guardrail` | `allow`, `deny` | -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 +The lifecycle trio is from `FLEX-WP-0010` / `TEN-WP-0005`. The last two are +the TEN-WP-0006 guardrail actions (`FLEX-WP-0014`). They are two actions +rather than one `tenant.guardrail` so policy can grant a PDP the read +without granting the write. That split is used today: `flex-auth` may +`tenant.guardrail.read` and may not `tenant.guardrail.set`; `tenant-engine` +may do both. Reasoning 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 -endpoints, consumed *by* `key-cape` and `flex-auth` respectively. +These are the only nine actions `tenant-engine` sends to `POST /v1/check` — +its `FlexAuthCheckClient` (`TEN-WP-0003`) never sends anything else. +`tenant-engine`'s role reads (`/roles`, `/roles/live`) are still not gated +through flex-auth; they remain `tenant-engine`'s own cache-read and +live-lookup endpoints. The guardrail GET is different: it is authorized, +because a ceiling is an input to a decision, not an unauthenticated lookup. ## Not in scope for this vocabulary diff --git a/docs/tenant-engine-resource-namespace.md b/docs/tenant-engine-resource-namespace.md index ef3a240..7842954 100644 --- a/docs/tenant-engine-resource-namespace.md +++ b/docs/tenant-engine-resource-namespace.md @@ -5,13 +5,13 @@ consumed by flex-auth (`FLEX-WP-0008-T01`). Unlike Markitect's document hierarchy or ops-warden's fixed SSH-certificate inventory, `tenant-engine`'s resources (tenants, their role grants, their -plan assignments) are created **dynamically** — there is no fixed, -pre-registrable resource list to publish a `resource_manifest.yaml` for. -Every `CheckRequest` carries the resource identity inline -(`resource.id == tenant_id`), and the policy evaluates against `input` -directly rather than looking up a registered resource by id. This is a -deliberate, documented deviation from the ops-warden pattern, not an -omission. +plan assignments, their guardrails) are created **dynamically** — there is +no fixed, pre-registrable resource list to publish a +`resource_manifest.yaml` for. Every `CheckRequest` carries the resource +identity inline (`resource.id == tenant_id`), and the policy evaluates +against `input` directly rather than looking up a registered resource by +id. This is a deliberate, documented deviation from the ops-warden +pattern, not an omission. ## Resource Types @@ -19,10 +19,13 @@ omission. tenant -- a tenant record (existence, grouping) role-grant -- a capability-role grant/revocation plan-assignment -- a tenant's plan/subscription assignment +guardrail -- a tenant's resolved ceiling and its provenance ``` -All three are `scope_level: Resource`, always accessed within -`system: tenant-engine`. +All four are `scope_level: Resource`, always accessed within +`system: tenant-engine`. A guardrail check still sends `resource.id` equal +to the tenant id — there is no separately addressable guardrail object on +the wire. ## Ownership Boundary diff --git a/examples/tenant-engine/README.md b/examples/tenant-engine/README.md index 8528e1f..7616d73 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 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 | +| `protected_system_manifest.yaml` | Resource types (`tenant`, `role-grant`, `plan-assignment`, `guardrail`) and the nine actions: the original four, the `FLEX-WP-0010` lifecycle trio, and the `FLEX-WP-0014` guardrail pair `tenant.guardrail.read` / `tenant.guardrail.set` | +| `subject_manifest.yaml` | Two registered callers: `tenant-engine` (all nine actions) and `flex-auth` (read-only on `tenant.guardrail.read`) | | `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`, `check_request_allow_retire.json`, `check_request_deny_misspelled_lifecycle.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`, `check_request_allow_guardrail_read.json`, `check_request_allow_guardrail_set.json`, `check_request_deny_guardrail_set_as_reader.json`, `check_request_deny_misspelled_guardrail.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. @@ -88,8 +88,33 @@ driven through the client, which only ever emits the seven registered strings | `tenant.retired` (typo) | deny `unknown_action` | `decision:30946c43cbe80bfc` | | unregistered subject | deny `unknown_subject` | `decision:7b107e73cf16fab9` | -Rollback target is `sha256:c25fc34a…` (four-action). TEN-WP-0006 guardrail -actions are **not** in this image. +Rollback target is `sha256:c25fc34a…` (four-action). + +## Verified — guardrail actions (FLEX-WP-0014-T03, 2026-08-16) + +`test-policy` reports **17/17 Rego tests and 23/23 fixtures passing**, the +registry loads (2 subjects, 2 groups), 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`: + +| Call | tenant-engine | flex-auth decision | +| --- | --- | --- | +| `POST /tenants` actor=`tenant-engine` | 201 | allow `write_api_policy_matched` | +| `GET .../guardrails?actor=flex-auth` | 200 grouping `spend.monthly=25000` | allow | +| `GET .../guardrails?actor=ops` | 403 `write_denied` | deny `unknown_subject` | +| `PUT .../guardrails/spend.monthly` actor=`tenant-engine` | 200 override `9000` v2 | allow | +| `PUT ...` actor=`flex-auth` | 403 `write_denied` | deny `action_not_granted` | +| `PUT ...` actor=`ops` | 403 `write_denied` | deny `unknown_subject` | + +**In source, not yet in the production image.** The live +`flex-auth-tenant-engine` Deployment still serves the seven-action +`sha256:9320df39` pin. A CI-built image from this commit plus a pin +restore is the remaining step before a live guardrail check succeeds +against the cluster Service. ## Related diff --git a/examples/tenant-engine/check_request_allow_guardrail_read.json b/examples/tenant-engine/check_request_allow_guardrail_read.json new file mode 100644 index 0000000..b622b71 --- /dev/null +++ b/examples/tenant-engine/check_request_allow_guardrail_read.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-guardrail-read-pdp", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "flex-auth", + "type": "service" + }, + "action": "tenant.guardrail.read", + "resource": { + "id": "t-1", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/check_request_allow_guardrail_set.json b/examples/tenant-engine/check_request_allow_guardrail_set.json new file mode 100644 index 0000000..fd15aba --- /dev/null +++ b/examples/tenant-engine/check_request_allow_guardrail_set.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-guardrail-set-writer", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "tenant-engine", + "type": "service" + }, + "action": "tenant.guardrail.set", + "resource": { + "id": "t-1", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/check_request_deny_guardrail_set_as_reader.json b/examples/tenant-engine/check_request_deny_guardrail_set_as_reader.json new file mode 100644 index 0000000..0b5316e --- /dev/null +++ b/examples/tenant-engine/check_request_deny_guardrail_set_as_reader.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-guardrail-set-pdp", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "flex-auth", + "type": "service" + }, + "action": "tenant.guardrail.set", + "resource": { + "id": "t-1", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/check_request_deny_misspelled_guardrail.json b/examples/tenant-engine/check_request_deny_misspelled_guardrail.json new file mode 100644 index 0000000..9beaffa --- /dev/null +++ b/examples/tenant-engine/check_request_deny_misspelled_guardrail.json @@ -0,0 +1,15 @@ +{ + "id": "check:tenant-engine-guardrail-get", + "tenant": "tenant:friendly:binky", + "subject": { + "id": "flex-auth", + "type": "service" + }, + "action": "tenant.guardrail.get", + "resource": { + "id": "t-1", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} +} diff --git a/examples/tenant-engine/policy_fixtures.yaml b/examples/tenant-engine/policy_fixtures.yaml index 8cc18ac..9e740ae 100644 --- a/examples/tenant-engine/policy_fixtures.yaml +++ b/examples/tenant-engine/policy_fixtures.yaml @@ -190,5 +190,89 @@ "context": {} }, "expect": {"effect": "deny", "reason": "wrong_subject_type"} + }, + { + "id": "fixture:tenant-engine-guardrail-read-pdp-allow", + "request": { + "id": "check:tenant-engine-guardrail-read-pdp", + "tenant": "tenant:friendly:binky", + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-guardrail-read-writer-allow", + "request": { + "id": "check:tenant-engine-guardrail-read-writer", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-guardrail-set-writer-allow", + "request": { + "id": "check:tenant-engine-guardrail-set-writer", + "tenant": "tenant:friendly:binky", + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.guardrail.set", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "allow", "reason": "write_api_policy_matched"} + }, + { + "id": "fixture:tenant-engine-guardrail-set-pdp-deny", + "request": { + "id": "check:tenant-engine-guardrail-set-pdp", + "tenant": "tenant:friendly:binky", + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.set", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "action_not_granted"} + }, + { + "id": "fixture:tenant-engine-guardrail-read-unknown-subject-deny", + "request": { + "id": "check:tenant-engine-guardrail-read-ops", + "tenant": "tenant:friendly:binky", + "subject": {"id": "ops", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_subject"} + }, + { + "id": "fixture:tenant-engine-guardrail-set-unknown-subject-deny", + "request": { + "id": "check:tenant-engine-guardrail-set-ops", + "tenant": "tenant:friendly:binky", + "subject": {"id": "ops", "type": "service"}, + "action": "tenant.guardrail.set", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_subject"} + }, + { + "id": "fixture:tenant-engine-misspelled-guardrail-action-deny", + "request": { + "id": "check:tenant-engine-guardrail-get", + "tenant": "tenant:friendly:binky", + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.get", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"}, + "context": {} + }, + "expect": {"effect": "deny", "reason": "unknown_action"} } ] diff --git a/examples/tenant-engine/policy_package.md b/examples/tenant-engine/policy_package.md index 99e10c1..1bccd44 100644 --- a/examples/tenant-engine/policy_package.md +++ b/examples/tenant-engine/policy_package.md @@ -13,6 +13,8 @@ actions: - tenant.update - tenant.retire - tenant.reactivate + - tenant.guardrail.read + - tenant.guardrail.set owner: team:platform-security fixtures: - policy_fixtures.yaml @@ -39,6 +41,7 @@ caring: - EditAny - Archive - Restore + - View - Audit exposure_modes: - Metadata @@ -114,6 +117,46 @@ 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. +## Guardrail actions (FLEX-WP-0014) + +`tenant.guardrail.read` and `tenant.guardrail.set` gate tenant-engine's +ceiling surface (`GET /tenants/{id}/guardrails`, +`PUT`/`DELETE /tenants/{id}/guardrails/{limit_key}`). The strings are the +exact values `authz.FlexAuthWriteAuthorizer` sends. Resource type is +`guardrail`; `resource.id` is still the tenant id. + +They are two actions because a PDP must be able to read ceilings without +being able to change them. tenant-engine is a data source, never a decision +maker; flex-auth is the intended reader. + +### Decision: use the read/write split now + +**Decision (FLEX-WP-0014-T02, 2026-08-16): yes.** `flex-auth` may +`tenant.guardrail.read` and may not `tenant.guardrail.set`. +`tenant-engine` may do both. Nobody else may do either. + +Reasoning: + +1. **The second subject exists and is named.** TEN-WP-0006 split the + actions so "policy can grant you [flex-auth] the read without granting + anything the write." Their own HTTP tests call the read as + `actor=flex-auth`. Leaving both actions on the single `tenant-engine` + subject would keep the seam unused and leave the intended reader + failing `unknown_subject`. +2. **This is a real difference, not a named no-op.** Unlike + FLEX-WP-0010-T02, there is a second service identity to distinguish + from, and a check the reader can fail (`tenant.guardrail.set` as + `flex-auth` → `action_not_granted`). +3. **Do not invent an `ops` write subject.** Production writes already + authorize only the `tenant-engine` service identity. Guardrail + mutations stay on that same identity. Adding `ops` would widen the + write set without a registered operator. +4. **Do not grant flex-auth any mutate action.** A PDP that can raise a + ceiling is no longer only a decision maker. + +**Revisit when** a second human/operator subject is registered, or when +flex-auth itself needs to read as a different subject id than `flex-auth`. + ## Rules ```rego @@ -129,9 +172,19 @@ valid_actions := { "tenant.update", "tenant.retire", "tenant.reactivate", + "tenant.guardrail.read", + "tenant.guardrail.set", } -known_operators := {"tenant-engine"} +read_actions := {"tenant.guardrail.read"} + +mutate_actions := valid_actions - read_actions + +known_subjects := {"tenant-engine", "flex-auth"} + +read_subjects := {"tenant-engine", "flex-auth"} + +mutate_subjects := {"tenant-engine"} decision := {"effect": "allow", "reason": "write_api_policy_matched"} if { allowed @@ -141,9 +194,16 @@ decision := {"effect": "allow", "reason": "write_api_policy_matched"} if { allowed if { input.resource.system == "tenant-engine" - input.action in valid_actions + input.action in read_actions input.subject.type == "service" - input.subject.id in known_operators + input.subject.id in read_subjects +} + +allowed if { + input.resource.system == "tenant-engine" + input.action in mutate_actions + input.subject.type == "service" + input.subject.id in mutate_subjects } default first_denial := "no_matching_rule" @@ -155,7 +215,9 @@ first_denial := "wrong_system" if { } else := "wrong_subject_type" if { input.subject.type != "service" } else := "unknown_subject" if { - not input.subject.id in known_operators + not input.subject.id in known_subjects +} else := "action_not_granted" if { + input.subject.id in known_subjects } ``` @@ -258,4 +320,52 @@ test_wrong_subject_type_denied if { "resource": {"id": "t-1", "type": "tenant", "system": "tenant-engine"} } } + +test_guardrail_read_by_pdp_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} + +test_guardrail_read_by_writer_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} + +test_guardrail_set_by_writer_allowed if { + write_api.decision.effect == "allow" with input as { + "subject": {"id": "tenant-engine", "type": "service"}, + "action": "tenant.guardrail.set", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} + +test_guardrail_set_by_pdp_denied if { + write_api.decision.reason == "action_not_granted" with input as { + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.set", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} + +test_guardrail_read_unknown_subject_denied if { + write_api.decision.reason == "unknown_subject" with input as { + "subject": {"id": "ops", "type": "service"}, + "action": "tenant.guardrail.read", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} + +test_misspelled_guardrail_action_denied if { + write_api.decision.reason == "unknown_action" with input as { + "subject": {"id": "flex-auth", "type": "service"}, + "action": "tenant.guardrail.get", + "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} + } +} ``` diff --git a/examples/tenant-engine/protected_system_manifest.yaml b/examples/tenant-engine/protected_system_manifest.yaml index 6e61a37..958a94f 100644 --- a/examples/tenant-engine/protected_system_manifest.yaml +++ b/examples/tenant-engine/protected_system_manifest.yaml @@ -23,6 +23,15 @@ resource_types: - Audit metadata: description: A tenant's plan/subscription assignment (by reference id). + - name: guardrail + scope_level: Resource + planes: + - Policy + - Audit + metadata: + description: >- + A tenant's resolved ceiling (spend, seats, and similar limits) plus + the provenance of that value. Not an entitlement and not a meter. actions: - name: tenant.create capabilities: @@ -105,6 +114,28 @@ actions: - Metadata metadata: required_context: [] + - name: tenant.guardrail.read + capabilities: + - View + - Audit + planes: + - Policy + - Audit + exposure_modes: + - Metadata + metadata: + required_context: [] + - name: tenant.guardrail.set + capabilities: + - EditAny + - Audit + planes: + - 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 fb5033a..de838b6 100644 --- a/examples/tenant-engine/registry_snapshot.json +++ b/examples/tenant-engine/registry_snapshot.json @@ -37,6 +37,17 @@ "metadata": { "description": "A tenant's plan/subscription assignment (by reference id)." } + }, + { + "name": "guardrail", + "scope_level": "Resource", + "planes": [ + "Policy", + "Audit" + ], + "metadata": { + "description": "A tenant's resolved ceiling (spend, seats, and similar limits) plus the provenance of that value. Not an entitlement and not a meter." + } } ], "actions": [ @@ -162,6 +173,40 @@ "metadata": { "required_context": [] } + }, + { + "name": "tenant.guardrail.read", + "capabilities": [ + "View", + "Audit" + ], + "planes": [ + "Policy", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } + }, + { + "name": "tenant.guardrail.set", + "capabilities": [ + "EditAny", + "Audit" + ], + "planes": [ + "Policy", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } } ], "caring_profiles": [ @@ -190,11 +235,28 @@ "Operator" ], "groups": [ - "group:tenant-engine-writers" + "group:tenant-engine-writers", + "group:tenant-engine-readers" ], "tenant": "tenant:platform", "metadata": { - "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." + "description": "tenant-engine's own service identity, used for the nine write-API actions it sends to POST /v1/check (authz.FlexAuthWriteAuthorizer): tenant.create, tenant.role.grant, tenant.role.revoke, tenant.plan.assign, the lifecycle actions tenant.update, tenant.retire, tenant.reactivate, and the guardrail actions tenant.guardrail.read, tenant.guardrail.set." + } + }, + { + "id": "flex-auth", + "type": "Service", + "display_name": "flex-auth service principal", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [ + "group:tenant-engine-readers" + ], + "tenant": "tenant:platform", + "metadata": { + "description": "flex-auth's own service identity. Authorized only for tenant.guardrail.read so the PDP can consume ceilings without being able to change them (FLEX-WP-0014)." } } ], @@ -206,6 +268,15 @@ "tenant-engine" ], "tenant": "tenant:platform" + }, + { + "id": "group:tenant-engine-readers", + "display_name": "tenant-engine Guardrail Readers", + "members": [ + "flex-auth", + "tenant-engine" + ], + "tenant": "tenant:platform" } ], "relationships": [] diff --git a/examples/tenant-engine/subject_manifest.yaml b/examples/tenant-engine/subject_manifest.yaml index a6f997f..987add3 100644 --- a/examples/tenant-engine/subject_manifest.yaml +++ b/examples/tenant-engine/subject_manifest.yaml @@ -11,17 +11,39 @@ subjects: - Operator groups: - group:tenant-engine-writers + - group:tenant-engine-readers tenant: tenant:platform metadata: description: >- - tenant-engine's own service identity, used for the seven write-API + tenant-engine's own service identity, used for the nine 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. + tenant.role.revoke, tenant.plan.assign, the lifecycle actions + tenant.update, tenant.retire, tenant.reactivate, and the + guardrail actions tenant.guardrail.read, tenant.guardrail.set. + - id: flex-auth + type: Service + display_name: flex-auth service principal + organization_relation: ServiceProvider + roles: + - Operator + groups: + - group:tenant-engine-readers + tenant: tenant:platform + metadata: + description: >- + flex-auth's own service identity. Authorized only for + tenant.guardrail.read so the PDP can consume ceilings without + being able to change them (FLEX-WP-0014). groups: - id: group:tenant-engine-writers display_name: tenant-engine Write API Callers members: - tenant-engine tenant: tenant:platform + - id: group:tenant-engine-readers + display_name: tenant-engine Guardrail Readers + members: + - flex-auth + - tenant-engine + tenant: tenant:platform diff --git a/workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md b/workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md new file mode 100644 index 0000000..0a134ed --- /dev/null +++ b/workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md @@ -0,0 +1,184 @@ +--- +id: FLEX-WP-0014 +type: workplan +title: "Authorize tenant-engine guardrail actions" +domain: infotech +repo: flex-auth +status: finished +owner: grok +topic_slug: netkingdom +planning_priority: P1 +planning_order: 140 +depends_on_workplans: + - FLEX-WP-0010 + - FLEX-WP-0013 +related_workplans: + - TEN-WP-0006 +created: "2026-08-16" +updated: "2026-08-16" +--- + +# FLEX-WP-0014 - Authorize tenant-engine guardrail actions + +Extend package `tenant-engine.write-api.mutate` from seven actions to nine +with the TEN-WP-0006 guardrail surface. Until they exist here, every +guardrail check resolves to `deny` `unknown_action` — correct fail-closed +behaviour, but the surface is inert. + +**Requested by:** `tenant-engine` (TEN-WP-0006, message `5572d198`). +**Blocked on:** FLEX-WP-0013 (four-action production pin). That restore is +done; this workplan starts on a current seven-action package, not a +rolled-back one. + +## Requirements from tenant-engine + +Two new actions, both on resource type `guardrail`, both against +`system: "tenant-engine"`: + +| Action | Resource type | HTTP surface | +|---|---|---| +| `tenant.guardrail.read` | `guardrail` | `GET /tenants/{id}/guardrails?actor=` | +| `tenant.guardrail.set` | `guardrail` | `PUT` / `DELETE /tenants/{id}/guardrails/{limit_key}` | + +Strings are live in `tenant-engine/src/tenant_engine/authz.py` +`_RESOURCE_TYPES`. Match them verbatim. `CheckRequest` shape is unchanged: +`subject_type: "service"`, `resource_id` = `tenant_id`. + +They are two actions so policy can grant a PDP the read without granting +the write. tenant-engine's own tests call the read as `actor=flex-auth`. + +`v1` stays the activated revision; the identifying fact is that `v1` as of +this commit contains the two guardrail actions. + +Do **not** deploy a new production image in this workplan until the package +is verified locally. Image + pin is a follow-on, named in T04. + +## T01 - Extend the policy package with the guardrail actions + +```task +id: FLEX-WP-0014-T01 +status: done +priority: high +``` + +Add `tenant.guardrail.read` and `tenant.guardrail.set` to +`valid_actions`, the frontmatter `actions:` list, and +`protected_system_manifest.yaml`. Add resource type `guardrail`. + +CARING: `View` for the read (already used by qonto-assistant), `EditAny` +for the set (already used by `tenant.update`). Extend +`caring.capabilities` with `View`. + +Register `flex-auth` as a second service subject that is allowed +`tenant.guardrail.read` and nothing else. Update `subject_manifest.yaml` +and rebuild `examples/tenant-engine/registry_snapshot.json`. + +Update `docs/tenant-engine-action-vocabulary.md` and +`docs/tenant-engine-resource-namespace.md`. Keep the existing note that +this policy governs who may call tenant-engine's admin API, not a tenant's +own capability roles. + +Done when `valid_actions` carries all nine actions, the new resource type +is declared, and `flex-auth` is a registered read-only subject. + +Done 2026-08-16: nine actions in frontmatter, `valid_actions`, and the +protected-system manifest. Resource type `guardrail` added. CARING +`View` on the read, `EditAny` on the set. `flex-auth` registered as a +read-only subject. Registry snapshot rebuilt (2 subjects, 2 groups). + +## T02 - Decide how the read/write split is used today + +```task +id: FLEX-WP-0014-T02 +status: done +priority: medium +``` + +tenant-engine split the actions so a PDP can read ceilings without being +able to change them. Decide whether that split is used now or only reserved. + +Record the decision in `policy_package.md`. Using it now — `flex-auth` may +read, `tenant-engine` may read and set, nobody else — is the expected +outcome unless a concrete reason says otherwise. Inventing an `ops` write +subject is out of scope; writes stay on the existing service identity. + +Done when the decision and its reasoning are recorded and implemented. + +Done 2026-08-16: **yes — use the split now.** Recorded under "Decision: +use the read/write split now" in +`examples/tenant-engine/policy_package.md`. `flex-auth` may +`tenant.guardrail.read` and is denied `action_not_granted` on +`tenant.guardrail.set`. `tenant-engine` may do both. No `ops` write +subject. + +## T03 - Fixtures and verification + +```task +id: FLEX-WP-0014-T03 +status: done +priority: high +``` + +Add fixture pairs for: authorized `tenant-engine` read and set → allow; +authorized `flex-auth` read → allow; `flex-auth` set → deny +`action_not_granted`; unknown subject → deny `unknown_subject`; misspelled +guardrail action → deny `unknown_action`. + +Verify against the real binary: `test-policy`, `load-registry`, `check` on +standalone request files. Then close the loop with a live `flex-auth serve` +and a real `tenant-engine` on `TENANT_ENGINE_FLEX_AUTH_URL`, through +unmodified `FlexAuthWriteAuthorizer`: + +- `GET /tenants/{id}/guardrails?actor=flex-auth` → 200 +- `GET ...?actor=ops` → 403 +- `PUT ...` as `actor=tenant-engine` → 200 +- `PUT ...` as `actor=flex-auth` → 403 +- `PUT ...` as `actor=ops` → 403 + +Done when those five calls match, `go test ./...` is green, and +`gofmt`/`go vet` are clean. + +Done 2026-08-16: 17/17 Rego tests, 23/23 fixtures. `load-registry` loads +clean. Standalone `check` files return the expected allow/deny. + +Live `flex-auth serve` on `127.0.0.1:9098` + real `tenant-engine` on +`TENANT_ENGINE_FLEX_AUTH_URL`: + +| Call | tenant-engine | flex-auth | +|---|---|---| +| `POST /tenants` actor=`tenant-engine` | 201 | allow | +| `GET .../guardrails?actor=flex-auth` | 200 `spend.monthly=25000` grouping | allow | +| `GET ...?actor=ops` | 403 `write_denied` | deny `unknown_subject` | +| `PUT .../spend.monthly` actor=`tenant-engine` | 200 override `9000` v2 | allow | +| `PUT ...` actor=`flex-auth` | 403 `write_denied` | deny `action_not_granted` | +| `PUT ...` actor=`ops` | 403 `write_denied` | deny `unknown_subject` | + +`go test ./...` green; `gofmt -l` empty; `go vet ./...` clean. + +## T04 - Closure and handoff + +```task +id: FLEX-WP-0014-T04 +status: done +priority: low +``` + +Confirm T01–T03. Notify `tenant-engine` naming the policy revision. State +plainly whether the nine-action package is **in source** or **deployed** — +FLEX-WP-0010 taught that distinction the hard way. Production rollout of a +new image is not implied by this workplan; it is a separate pin after CI +publishes a digest. + +Done when the handoff is sent and this workplan is `finished`. + +Done 2026-08-16: T01–T03 confirmed. Policy revision for the handoff is +package `tenant-engine.write-api.mutate` **version `v1`**, now carrying +all nine actions. The version string is unchanged because the package is +additive. + +**In source, not in the cluster.** Production `flex-auth-tenant-engine` +still serves `sha256:9320df39` (seven actions). A CI-built image from +this commit plus a pin of `flex-auth-tenant-engine` is the remaining +step before a live guardrail check succeeds against the cluster Service. +tenant-engine's own TEN-WP-0006 surface is also not deployed, so the +cluster path is not yet the binding one.