tenant-engine/docs/flex-auth-integration.md
tegwick 6c137c1851 TEN-IN-0004 closed: repoint access-engine paths; TEN-DEC-2026-003 answers NK-WP-0039-T04
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 101414@bnt-lap001
Assistant-Session: 60b5a221-d435-42b9-b778-983c90c7eed6
2026-09-30 18:52:12 +02:00

7.3 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 access-engine/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 access-engine/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 (access-engine/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, access-engine/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 was renamed 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 changed to access-engine/... when the rename landed (2026-09-30, FLEX-WP-0020).

  • pep-stance.yaml — published fail-closed map
  • pip-claims.yaml — input-class freshness
  • access-engine/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