--- id: TEN-WP-0011 type: workplan title: "Align tenant-engine with the accepted security layer model" domain: infotech repo: tenant-engine status: finished owner: grok topic_slug: netkingdom created: "2026-08-29" updated: "2026-08-29" depends_on: - TEN-WP-0003 - TEN-WP-0006 - TEN-WP-0009 unblocks: [] origin: residual origin_ref: TEN-IN-0003 state_hub_workstream_id: "43ad25c6-7149-53d1-8012-817a061ace92" --- # TEN-WP-0011 — Align tenant-engine with the accepted security layer model The NetKingdom Security Layer Model is accepted at v0.7. This repository is catalogued Engine / PIP for tenant-as-an-entity facts. `INTENT.md` now declares that in its own voice (`TEN-DEC-2026-001`). The remaining work is to make the shipped engine match the obligations that declaration takes on. Review of record: `history/2026-08-29-security-layer-intent-scope-review.md`. Start here: `net-kingdom/SECURITY-COMPANION.md` v0.2. Statute: `net-kingdom/canon/standards/security-layer-model_v0.7.md`. On disagreement the statute governs. ## Why this is a workplan Declaration is documentation. Conformance is mechanical. Companion §2 offers `layer.yaml` plus a check as the working reference. Companion §5 and statute §6.4 bind the write path as PEP-shaped. Statute §9.6 and the already-open intakes `TEN-IN-0001` / `TEN-IN-0002` bind evidence. Statute §9.7 requires a published lifetime on PIP input classes. This is not a layer change (statute §10). We are declaring the layer we already occupy and closing the gaps between that layer's rules and the code. ## Non-goals - Not a second PDP. Guardrail resolution stays a ceiling, never an allow. - Not an approval engine. `TEN-DEC-2026-001` contested that placement. - Not containment or production observation (companion §10). - Not staged promotion — that remains `TEN-WP-0008`. - Not forking the boundary contract. T06 requests an amendment in `net-kingdom`; it does not copy the contract into this repo. ## T01 — Machine-readable layer declaration and conformance check ```task id: TEN-WP-0011-T01 status: done priority: high state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72" ``` Add `layer.yaml` modelled on `ops-warden`'s reference and `kings-guard`'s no-Tooling adaptation: `layer: engine`, `role: pip`, `standard_version: "0.7"`, empty `tooling_contacts`, and a complete `non_tooling_clients` inventory (PostgreSQL / SQLite store, `flex-auth` `/v1/check`, State Hub work records). Add a check script and a test so a new catalogued Tooling client (`key-cape`, OpenBao) fails on the commit that introduces it. Record non-Tooling clients so the check is total (companion §4). Done when: `INTENT.md` frontmatter and `layer.yaml` agree on layer and role; `make test` covers the check; no OpenBao / `key-cape` client exists in `src/`. ## T02 — PEP write-path: decision records and published fail-closed stance ```task id: TEN-WP-0011-T02 status: done priority: high state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0" ``` `FlexAuthCheckClient.is_allowed()` currently returns a bool and drops the decision envelope. The boundary contract's Audit Correlation Contract already required `authorization_decision_id` on every mutation; the code never stored it. - Keep the decision record (id, effect, request digest) on the authorizer seam and persist it on every mutation event. - Where `access-engine` is unset or unreachable, record the **application of the stance** in place of a decision (companion §5.1). Do not proceed silently. - Publish the unreachable-engine stance as a file named from `layer.yaml` (fail-closed for every scope this engine serves — this is not ops-warden's per-zone fail-open map). Assert with a test that the published map equals shipped behaviour (`DefaultDenyWriteAuthorizer` and transport-failure deny). - Lock obligation 2: no verdict cache. Re-check every write. Add a test that a previous allow cannot authorize a later, different request. - Offer the map to gate-house for the statute §13.1 register. Do not edit the statute from this repo. Done when: a granted role, a denied grant, and an unreachable-engine attempt each leave a reconstructable record; `make test` proves stance file == code; no write path can succeed without one of those two records. ## T03 — PIP claim freshness, and prove live-lookup is not cyclic ```task id: TEN-WP-0011-T03 status: done priority: high state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111" ``` Statute §9.7: every allow has a lifetime, and a PDP states a deadline per input class. This engine owes the input-class description. - Publish, next to the stance map, the freshness contract for `tenant_roles` / grouping / plan / guardrail claims: cache-read is bounded by the IAM Profile token lifetime; live-lookup results MUST NOT be cached across requests; request-scoped memoization inside one `access-engine` decision remains allowed (boundary contract). - Name the consumer-visible failure: store unavailability is `503`, never empty roles. That is already shipped; make it part of the published contract so `access-engine` can treat it as input degradation (§9.3). - **Prove** that `GET /tenants/{id}/roles/live` → `POST /v1/check` (`tenant.role.read.live`) does not re-enter tenant-engine for the claims that check needs. If it does, the live path is cyclic and must be unwound (static service-identity rule, or an authorizer bypass that is itself a declared exception — prefer the former). Record the proof in `docs/flex-auth-integration.md`. Done when: the freshness contract is published; a test fails if live-lookup cross-request caching is introduced; the re-entry question is answered with evidence, not a comment. ## T04 — Externalize mutation evidence to audit-core ```task id: TEN-WP-0011-T04 status: done priority: high state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d" ``` Promoted from `TEN-IN-0001` (origin: residual of `TEN-WP-0009`). The local `events` table shares a database and runtime credential with the facts it attests to. Statute §9.6: that trail cannot be described as independent custody. Classify current classes as **attributive** unless a named control branches on their presence. Design and implement a governed tenant-engine → `audit-core` emission path: 1. Agree envelope, sender registration, and credential custody with `audit-core` without putting a secret in Git or a work record. Route credentials through `warden route`, never through a State Hub message. 2. Preserve mutation atomicity (local outbox against crash) while defining retry, duplicate, and audit-core-unavailable semantics. Unavailable evidence MUST NOT fail-open a mutation, and SHOULD NOT fail-closed a mutation either unless a class is later reclassified load-bearing — declare the trade if emission stays attributive and non-blocking. 3. Prove the external copy cannot be rewritten through tenant-engine's runtime database credential. 4. Decide whether pre-cutover event classes need a bounded backfill, and record the decision. 5. Do not claim the archive proves events happened, or that absence proves they did not. Done when: production mutations emit to `audit-core` under the bound above; the trade (atomic vs non-blocking) is declared; `TEN-IN-0001` can close as promoted and completed. **Done 2026-08-29:** local outbox is in the mutation transaction; drain POSTs `/v1/events` and never fails the mutation; trade declared in `docs/evidence-emission.md`; no pre-cutover backfill. Production landing waits on sender registration, requested as `AUDIT-IN-0002`. ## T05 — Remove or authorize the unfiltered event-read interface ```task id: TEN-WP-0011-T05 status: done priority: high state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc" ``` Promoted from `TEN-IN-0002` (origin: RISK-F-0004). `TenantStore.events()` returns every tenant's event and payload. There is no HTTP event-read route today, but the production protocol still offers the dump. - Remove `events()` from the production protocol, **or** replace it with an authorized, deliberately scoped export/query that cannot return another tenant's payload. - Add cross-tenant negative tests. - Hand risk-nexus the accessibility and payload reading. Done when: a production caller cannot list another tenant's events through this repo's store protocol; tests say so; `TEN-IN-0002` can close. **Done 2026-08-29:** `events()` removed from the production protocol; `events_for(tenant_id)` is the only read. Cross-tenant negative test in `tests/test_events_scoped.py`. Reading handed to risk-nexus. ## T06 — Request the boundary-contract amendment ```task id: TEN-WP-0011-T06 status: done priority: medium state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776" ``` `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` still says guardrail/quota policy is "reserved, not yet implemented" and that `tenant-engine` is "not a policy enforcement point". Both sentences now mislead: guardrails shipped in `TEN-WP-0006`, and writes are PEP-shaped even though this repo is not a PDP. File an intake on `net-kingdom` (do not edit the canon from here) asking to: - mark guardrail/quota policy as owned and implemented, pointing at `docs/tenant-guardrail-policy.md`; - restate the authorization contract: data source / PIP for decisions; PEP-shaped for its own protected writes; never a second PDP; - mention Engine / PIP and the v0.7 statute next to the existing IAM Profile / ADR-0013 / ADR-0014 references. Done when: the intake exists on the canon side with a named id, and this task records it. **Done 2026-08-29:** filed `NET-IN-0002` on `net-kingdom` (`net-kingdom/intakes/intakes.md`).