tenant-engine/docs/flex-auth-integration.md
tegwick d132db064f Name what CheckRequest.tenant denotes; record the access-engine rename intake
TEN-DEC-2026-002 answers flex-auth's FLEX-WP-0022-T01, open since
2026-09-15: `tenant` denotes the target tenant record, equals
`resource.id` by intent, and the write API is cross-tenant by design —
no action is refused on the subject/tenant relationship, and
tenant.guardrail.read must not differ because flex-auth itself calls it
across tenants. docs/flex-auth-integration.md states the relation in
this repo's voice.

TEN-IN-0004 is the live record flex-auth asked for on FLEX-WP-0020.
Runtime names stay flex-auth (FLEX-DEC-2026-013) and all deploy, cluster
and settings coordinates verify as retained; only five documentation
repository paths change when the rename lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
2026-09-21 02:09:55 +02:00

7.2 KiB

flex-auth Integration

tenant-engine gates every write, and every authorized read, through flex-auth's POST /v1/check (flex_auth.FlexAuthCheckClient, wired in as authz.FlexAuthWriteAuthorizer). Writes are PEP-shaped: no mutation without a decision record or a recorded fail-closed stance (pep-stance.yaml).

What tenant-engine sends

A CheckRequest per flex-auth/schemas/check_request.schema.json:

{
  "id": "check:<uuid>",
  "tenant": "tenant:friendly:binky",
  "subject": {"id": "<actor>", "type": "service"},
  "action": "tenant.create",
  "resource": {"id": "<tenant_id>", "type": "tenant", "system": "tenant-engine"},
  "context": {}
}

context is always empty. This engine never puts tenant_roles or any other PIP claim on the check it sends. The check asks whether this caller may use this admin/PIP surface, not what the tenant is allowed to do on the platform.

What tenant denotes (TEN-DEC-2026-002)

tenant is the target tenant record — the tenant the action operates on. It is not the caller's tenant, and on guardrail actions it is not a separate "tenant the guardrail applies to": a guardrail is addressed only through the tenant it constrains, so that is the same tenant.

tenant always equals resource.id. That is intended, not incidental: one tenant_id is copied onto both fields in authz.FlexAuthWriteAuthorizer. A check where the two differ did not come from this engine.

tenant cannot denote the caller's tenant because this service does not know it. No inbound token is verified; the acting identity arrives as a caller-supplied actor string (tenancy.yaml I1). There is no caller tenant in scope when the check is built.

The write API is cross-tenant by design. No action in the vocabulary is refused on the relationship between subject and tenant. The subjects are platform service identities in tenant:platform; the targets are arbitrary tenant records. Creating and retiring tenants cannot be done from inside the tenant concerned, and tenant.create has no existing target at check time, so a same-tenant rule would deny every create. Authorization here is a service-identity question over (subject.id, action); tenant and resource.id say which record is being touched, for the decision record and the audit trail.

tenant.guardrail.read does not differ, and must not: flex-auth calls it while deciding about arbitrary tenants, so a same-tenant rule on it would break the PDP itself.

Varying tenant in a conformance fixture must not change the effect. That is the assertion this engine wants the published package to make — an unrestricted scope that is stated is a rule a reviewer can check; an unrestricted scope that is merely unwritten is indistinguishable from an omitted one.

Action → resource-type mapping (must match flex-auth's FLEX-WP-0008-T01 vocabulary exactly — coordinate values, don't diverge):

Action Resource type Surface
tenant.create tenant write
tenant.role.grant role-grant write
tenant.role.revoke role-grant write
tenant.plan.assign plan-assignment write
tenant.update tenant write
tenant.retire tenant write
tenant.reactivate tenant write
tenant.grouping.set tenant write
tenant.guardrail.read guardrail read
tenant.guardrail.set guardrail write
tenant.read tenant read
tenant.role.read role-grant read (cache-read)
tenant.role.read.live role-grant read (live-lookup)

What tenant-engine expects back

A DecisionEnvelope per flex-auth/schemas/decision_envelope.schema.json. Only effect: "allow" authorizes the action. Every other effect, a non-200 response, a malformed body, or a transport failure/timeout all resolve to deny. FlexAuthCheckClient.check() never raises past its own boundary; it returns a CheckResult that is either a decision or a recorded application of the fail-closed stance.

The decision id, request digest, effect, and source are persisted on authz_records for every attempt and on the mutation event payload for every successful write. Verdicts are never cached: every call is a new POST (pep-stance.yaml verdict_caching: none).

Live-lookup is not cyclic (TEN-WP-0011-T03)

GET /tenants/{id}/roles/live authorizes via tenant.role.read.live then reads the store. That check does not re-enter tenant-engine:

  1. The CheckRequest this engine sends has empty context and does not carry tenant_roles.
  2. FlexAuthCheckClient POSTs only /v1/check. Proven by tests/test_pip_claims.py::test_live_lookup_check_does_not_reenter_tenant_engine.
  3. flex-auth's tenant-engine policy package (flex-auth/examples/tenant-engine/policy_package.md) matches subject.id and action only. It does not consult tenant capability roles. The package's own scope note says those roles are tenant state a different protected system might consult via live-lookup; conflating the two would authorize the wrong thing.
  4. flex-auth's live-roles adapter is built and unwired (FLEX-WP-0015, flex-auth/docs/tenancy-posture-review.md): no non-test caller, no current policy consumes tenant_roles for a privileged path.

Together: GET /roles/live → POST /v1/check is a service-identity question. The PDP does not need tenant-engine claims to answer it, so it does not call back.

Note: tenant.role.read / tenant.role.read.live / tenant.read / tenant.grouping.set are not yet in that policy package's valid_actions. A live flex-auth will currently unknown_action those four. That is itself evidence they are not evaluated via tenant-role claims. Adding them as static service-identity rules (same shape as tenant.guardrail.read) is flex-auth work, not a cycle to unwind here.

Unreachable-engine stance

Published in pep-stance.yaml. Every scope is fail_closed. DefaultDeny (URL unset) and transport failure both apply that stance and record it. Tests assert the file equals shipped behaviour.

Configuration

Env var Default Meaning
TENANT_ENGINE_FLEX_AUTH_URL unset Base URL of a reachable flex-auth deployment. When unset, create_app() falls back to authz.DefaultDenyWriteAuthorizer.
TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS 3 Bounded timeout on the synchronous path — no retries.
TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE unset Rotating bearer token file, read on each check.

Repository coordinate vs runtime name (TEN-IN-0004)

The flex-auth repository is renaming to access-engine (FLEX-WP-0020). Runtime names stay flex-auth per FLEX-DEC-2026-013: the cluster namespace, the flex-auth-tenant-engine Service, the token audience, and this engine's TENANT_ENGINE_FLEX_AUTH_* settings are all unchanged by the rename. Only the flex-auth/... repository paths cited below and above change, and only once the rename lands.

  • pep-stance.yaml — published fail-closed map
  • pip-claims.yaml — input-class freshness
  • flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md
  • key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md
  • net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md