fix: authorize native user portal tenant onboarding
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 57s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 20:52:46 +02:00
parent 88b354377c
commit dd8dd51743
3 changed files with 378 additions and 4 deletions

View file

@ -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"}}
}
```