From dd8dd517438b876fdadf27770e3f7e7f55ea69cf Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 20:52:46 +0200 Subject: [PATCH] fix: authorize native user portal tenant onboarding Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- examples/tenant-engine/policy_fixtures.yaml | 264 ++++++++++++++++++ examples/tenant-engine/policy_package.md | 85 +++++- examples/tenant-engine/registry_snapshot.json | 33 ++- 3 files changed, 378 insertions(+), 4 deletions(-) diff --git a/examples/tenant-engine/policy_fixtures.yaml b/examples/tenant-engine/policy_fixtures.yaml index 9e740ae..f686e55 100644 --- a/examples/tenant-engine/policy_fixtures.yaml +++ b/examples/tenant-engine/policy_fixtures.yaml @@ -274,5 +274,269 @@ "context": {} }, "expect": {"effect": "deny", "reason": "unknown_action"} + }, + { + "id": "fixture:tenant-engine-portal-create", + "request": { + "id": "check:portal-create", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.create", + "resource": { + "id": "tenant:trial:demo-company", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "allow", + "reason": "write_api_policy_matched" + } + }, + { + "id": "fixture:tenant-engine-portal-read", + "request": { + "id": "check:portal-read", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.read", + "resource": { + "id": "tenant:trial:demo-company", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "allow", + "reason": "write_api_policy_matched" + } + }, + { + "id": "fixture:tenant-engine-portal-platform-create", + "request": { + "id": "check:portal-platform-create", + "tenant": "tenant:platform", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.create", + "resource": { + "id": "tenant:platform", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-platform-read", + "request": { + "id": "check:portal-platform-read", + "tenant": "tenant:platform", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.read", + "resource": { + "id": "tenant:platform", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-role-grant", + "request": { + "id": "check:portal-role-grant", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.role.grant", + "resource": { + "id": "tenant:trial:demo-company", + "type": "role-grant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-role-revoke", + "request": { + "id": "check:portal-role-revoke", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.role.revoke", + "resource": { + "id": "tenant:trial:demo-company", + "type": "role-grant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-plan-assign", + "request": { + "id": "check:portal-plan-assign", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.plan.assign", + "resource": { + "id": "tenant:trial:demo-company", + "type": "plan-assignment", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-update", + "request": { + "id": "check:portal-update", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.update", + "resource": { + "id": "tenant:trial:demo-company", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-retire", + "request": { + "id": "check:portal-retire", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.retire", + "resource": { + "id": "tenant:trial:demo-company", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-reactivate", + "request": { + "id": "check:portal-reactivate", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.reactivate", + "resource": { + "id": "tenant:trial:demo-company", + "type": "tenant", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-guardrail-read", + "request": { + "id": "check:portal-guardrail-read", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.guardrail.read", + "resource": { + "id": "tenant:trial:demo-company", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } + }, + { + "id": "fixture:tenant-engine-portal-guardrail-set", + "request": { + "id": "check:portal-guardrail-set", + "tenant": "tenant:trial:demo-company", + "subject": { + "id": "user-engine", + "type": "service" + }, + "action": "tenant.guardrail.set", + "resource": { + "id": "tenant:trial:demo-company", + "type": "guardrail", + "system": "tenant-engine" + }, + "context": {} + }, + "expect": { + "effect": "deny", + "reason": "action_not_granted" + } } ] diff --git a/examples/tenant-engine/policy_package.md b/examples/tenant-engine/policy_package.md index 1bccd44..e5a8b39 100644 --- a/examples/tenant-engine/policy_package.md +++ b/examples/tenant-engine/policy_package.md @@ -2,10 +2,11 @@ id: tenant-engine.write-api.mutate name: tenant-engine Write API authorization namespace: tenant-engine:tenant -version: v1 +version: v2 status: ready package: flexauth.tenant_engine.write_api actions: + - tenant.read - tenant.create - tenant.role.grant - tenant.role.revoke @@ -157,6 +158,26 @@ Reasoning: **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`. +## User portal onboarding (NK-WP-0036, 2026-09-11) + +The native User Engine platform portal is the existing tenant-onboarding PEP. +It authenticates the human through KeyCape and checks `tenant:platform` plus +`platform-operator` before calling Tenant Engine as `user-engine`. Register +that real service subject for `tenant.create` and `tenant.read` on non-platform +`tenant` resources only. No role grants, lifecycle mutations, plan assignment +or guardrail changes are granted to it. Tenant Engine's own read is also +registered because its shipped read endpoint now calls `tenant.read`. + +This repairs the demonstrated native demo-company onboarding refusal; it does +not rename the caller to tenant-engine or weaken inbound caller authentication. +The PDP's authenticated caller remains the exact Tenant Engine ServiceAccount, +bound to resource.system tenant-engine. Tenant Engine's namespace-and-pod ingress +continues to admit only User Engine. Human operator entitlement stays with the +portal's existing PEP; no client-supplied human assurance is invented here. +The earlier single-write-subject decisions above describe their dated baseline; +this explicit minimal onboarding grant supersedes that baseline only for these +operations and this existing service integration. + ## Rules ```rego @@ -165,6 +186,7 @@ import future.keywords.if import future.keywords.in valid_actions := { + "tenant.read", "tenant.create", "tenant.role.grant", "tenant.role.revoke", @@ -178,9 +200,9 @@ valid_actions := { read_actions := {"tenant.guardrail.read"} -mutate_actions := valid_actions - read_actions +mutate_actions := valid_actions - read_actions - {"tenant.read"} -known_subjects := {"tenant-engine", "flex-auth"} +known_subjects := {"tenant-engine", "flex-auth", "user-engine"} read_subjects := {"tenant-engine", "flex-auth"} @@ -206,6 +228,23 @@ allowed if { input.subject.id in mutate_subjects } +allowed if { + input.resource.system == "tenant-engine" + input.resource.type == "tenant" + input.resource.id != "tenant:platform" + input.action in {"tenant.create", "tenant.read"} + input.subject.type == "service" + input.subject.id == "user-engine" +} + +allowed if { + input.resource.system == "tenant-engine" + input.resource.type == "tenant" + input.action == "tenant.read" + input.subject.type == "service" + input.subject.id == "tenant-engine" +} + default first_denial := "no_matching_rule" first_denial := "wrong_system" if { @@ -368,4 +407,44 @@ test_misspelled_guardrail_action_denied if { "resource": {"id": "t-1", "type": "guardrail", "system": "tenant-engine"} } } + +test_portal_create if { + write_api.decision.effect == "allow" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.create", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} + +test_portal_read if { + write_api.decision.effect == "allow" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.read", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} + +test_owner_read if { + write_api.decision.effect == "allow" with input as {"subject": {"id": "tenant-engine", "type": "service"}, "action": "tenant.read", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} + +test_portal_platform if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.create", "resource": {"id": "tenant:platform", "type": "tenant", "system": "tenant-engine"}} +} + +test_portal_wrong_type if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.create", "resource": {"id": "tenant:trial:demo-company", "type": "guardrail", "system": "tenant-engine"}} +} + +test_portal_no_grants if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.role.grant", "resource": {"id": "tenant:trial:demo-company", "type": "role-grant", "system": "tenant-engine"}} +} + +test_portal_no_retirement if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.retire", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} + +test_portal_no_guardrail if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "user-engine", "type": "service"}, "action": "tenant.guardrail.set", "resource": {"id": "tenant:trial:demo-company", "type": "guardrail", "system": "tenant-engine"}} +} + +test_pdp_no_tenant_read if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "flex-auth", "type": "service"}, "action": "tenant.read", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} + +test_unknown_no_read if { + write_api.decision.effect == "deny" with input as {"subject": {"id": "unknown", "type": "service"}, "action": "tenant.read", "resource": {"id": "tenant:trial:demo-company", "type": "tenant", "system": "tenant-engine"}} +} ``` diff --git a/examples/tenant-engine/registry_snapshot.json b/examples/tenant-engine/registry_snapshot.json index de838b6..c06e2e0 100644 --- a/examples/tenant-engine/registry_snapshot.json +++ b/examples/tenant-engine/registry_snapshot.json @@ -207,6 +207,23 @@ "metadata": { "required_context": [] } + }, + { + "name": "tenant.read", + "capabilities": [ + "View", + "Audit" + ], + "planes": [ + "Identity", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [] + } } ], "caring_profiles": [ @@ -240,7 +257,7 @@ ], "tenant": "tenant:platform", "metadata": { - "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." + "description": "tenant-engine's own service identity, used for tenant.read and the nine original 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." } }, { @@ -258,6 +275,20 @@ "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)." } + }, + { + "id": "user-engine", + "type": "Service", + "display_name": "User Engine tenant onboarding", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [], + "tenant": "tenant:platform", + "metadata": { + "description": "Existing KeyCape-authenticated platform portal PEP; may create/read non-platform tenant records only. No role, lifecycle, plan or guardrail mutation grant. NK-WP-0036." + } } ], "groups": [