Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client scan). Writes persist a decision record or the published fail-closed stance, live-lookup freshness is published, events_for is tenant-scoped, and mutation evidence drains to audit-core from a local outbox without blocking the mutation. Sender registration is requested as AUDIT-IN-0002. Boundary-contract amendment is requested as NET-IN-0002. Assistant: grok Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
This commit is contained in:
parent
80961af91e
commit
672cf4da6e
40 changed files with 2285 additions and 361 deletions
12
SCOPE.md
12
SCOPE.md
|
|
@ -104,12 +104,12 @@ not done.
|
||||||
| PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` |
|
| PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` |
|
||||||
| Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical |
|
| Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical |
|
||||||
| Staged promotion | **no** | `TEN-WP-0008` |
|
| Staged promotion | **no** | `TEN-WP-0008` |
|
||||||
| Machine-readable `layer.yaml` | **no** | Frontmatter declaration is in `INTENT.md` |
|
| Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` |
|
||||||
| Persisted `authorization_decision_id` | **no** | Boundary contract requires it; `FlexAuthCheckClient` drops the envelope |
|
| Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) |
|
||||||
| Published unreachable-engine stance map | **no** | Behaviour is fail-closed in code; companion §5 requires it published and tested equal to shipped behaviour |
|
| Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour |
|
||||||
| Independent audit-core emission | **no** | Local `events` table only (`TEN-IN-0001`) |
|
| Independent audit-core emission | path shipped, sender pending | Local outbox + POST `/v1/events`; `AUDIT-IN-0002` |
|
||||||
| Bounded event-read interface | **no** | In-process `TenantStore.events()` is unfiltered (`TEN-IN-0002`) |
|
| Bounded event-read interface | yes | `events_for(tenant_id)` only (`TEN-WP-0011-T05`) |
|
||||||
| Claim freshness / input-class lifetime | **no** | Token lifetime covers the cache-read path by profile convention; live-lookup has no declared deadline |
|
| Claim freshness / input-class lifetime | yes | `pip-claims.yaml` (`TEN-WP-0011-T03`) |
|
||||||
|
|
||||||
The boundary contract still labels guardrail policy "reserved, not
|
The boundary contract still labels guardrail policy "reserved, not
|
||||||
implemented" and still calls this repo "not a policy enforcement point".
|
implemented" and still calls this repo "not a policy enforcement point".
|
||||||
|
|
|
||||||
61
docs/evidence-emission.md
Normal file
61
docs/evidence-emission.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Mutation evidence emission (TEN-WP-0011-T04)
|
||||||
|
|
||||||
|
Statute §9.6. Companion §6. Current event classes are **attributive**:
|
||||||
|
no control in this engine branches on an event's presence. Role grants
|
||||||
|
as *facts* are live PIP input; the *events* are the trail.
|
||||||
|
|
||||||
|
## Bound
|
||||||
|
|
||||||
|
The local `events` table and the local `audit_outbox` prove that the
|
||||||
|
records they hold were not altered or truncated after arrival. They do
|
||||||
|
not prove an event happened, and absence is not evidence of
|
||||||
|
non-occurrence. They share this service's runtime database credential,
|
||||||
|
so they are not independent custody.
|
||||||
|
|
||||||
|
Independent custody belongs to `audit-core`. This engine POSTs
|
||||||
|
`/v1/events` and holds no SQL, no admin, and no rewrite path against
|
||||||
|
audit-core's store. The external copy therefore cannot be rewritten
|
||||||
|
through tenant-engine's database credential.
|
||||||
|
|
||||||
|
## Trade (declared)
|
||||||
|
|
||||||
|
| Step | Atomic with mutation? | If it fails |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Insert local `events` row | yes (same transaction) | mutation rolls back |
|
||||||
|
| Insert local `audit_outbox` row | yes (same transaction) | mutation rolls back |
|
||||||
|
| Drain outbox to audit-core | **no** — after commit | mutation already succeeded; row stays pending |
|
||||||
|
|
||||||
|
Emission is **non-blocking**. Unavailable audit-core MUST NOT fail-open
|
||||||
|
a mutation (the fact is already written) and MUST NOT fail-closed a
|
||||||
|
mutation (attributive evidence is not load-bearing). Completeness is
|
||||||
|
not claimed. If a future control starts branching on these events, that
|
||||||
|
class must be reclassified load-bearing before it ships, and this trade
|
||||||
|
revisited.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
`audit-core.event.v1alpha1`. `source` is `tenant-engine`. See
|
||||||
|
`tenant_engine.audit_core.envelope_for`. Duplicate event ids are 200
|
||||||
|
and not retried; 400/409 dead-letter; 503/transport retry.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
No secret in Git. Production sender token is projected as
|
||||||
|
`TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE`, routed through `warden route`
|
||||||
|
(`audit-core` sender registration), never through a State Hub message.
|
||||||
|
Sender registration itself is requested as `AUDIT-IN-0002`.
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
|
||||||
|
**Decision:** no backfill of pre-cutover event classes. Those rows stay
|
||||||
|
in the local table. Reconstructing them into audit-core would mint
|
||||||
|
evidence this engine cannot prove was complete at the time. New
|
||||||
|
mutations from this workplan onward enqueue the outbox.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `TENANT_ENGINE_AUDIT_CORE_URL` | unset | Drain is skipped; outbox rows stay pending. |
|
||||||
|
| `TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE` | unset | Bearer token file for POST /v1/events. |
|
||||||
|
| `TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS` | `3` | Drain timeout. |
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
# flex-auth Integration (TEN-WP-0003)
|
# flex-auth Integration
|
||||||
|
|
||||||
`tenant-engine` gates every write through flex-auth's `POST /v1/check`
|
`tenant-engine` gates every write, and every authorized read, through
|
||||||
(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`).
|
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
|
## What tenant-engine sends
|
||||||
|
|
||||||
|
|
@ -13,56 +16,98 @@ A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`:
|
||||||
"tenant": "tenant:friendly:binky",
|
"tenant": "tenant:friendly:binky",
|
||||||
"subject": {"id": "<actor>", "type": "service"},
|
"subject": {"id": "<actor>", "type": "service"},
|
||||||
"action": "tenant.create",
|
"action": "tenant.create",
|
||||||
"resource": {"id": "<tenant_id>", "type": "tenant", "system": "tenant-engine"}
|
"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.
|
||||||
|
|
||||||
Action → resource-type mapping (must match `flex-auth`'s
|
Action → resource-type mapping (must match `flex-auth`'s
|
||||||
`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge):
|
`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge):
|
||||||
|
|
||||||
| Action | Resource type |
|
| Action | Resource type | Surface |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| `tenant.create` | `tenant` |
|
| `tenant.create` | `tenant` | write |
|
||||||
| `tenant.role.grant` | `role-grant` |
|
| `tenant.role.grant` | `role-grant` | write |
|
||||||
| `tenant.role.revoke` | `role-grant` |
|
| `tenant.role.revoke` | `role-grant` | write |
|
||||||
| `tenant.plan.assign` | `plan-assignment` |
|
| `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
|
## What tenant-engine expects back
|
||||||
|
|
||||||
A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`.
|
A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`.
|
||||||
Only `effect: "allow"` authorizes the write. Every other `effect`
|
Only `effect: "allow"` authorizes the action. Every other `effect`, a
|
||||||
(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a
|
non-200 response, a malformed body, or a transport failure/timeout all
|
||||||
malformed body, or a transport failure/timeout all resolve to **deny** —
|
resolve to **deny**. `FlexAuthCheckClient.check()` never raises past its
|
||||||
`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it
|
own boundary; it returns a `CheckResult` that is either a decision or a
|
||||||
always returns a plain `bool`.
|
recorded application of the fail-closed stance.
|
||||||
|
|
||||||
## The current real state
|
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`).
|
||||||
|
|
||||||
Until `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
|
## Live-lookup is not cyclic (TEN-WP-0011-T03)
|
||||||
lands (resource/action vocabulary + an authored policy package), **every
|
|
||||||
check resolves to deny or not_applicable** — verified against real
|
`GET /tenants/{id}/roles/live` authorizes via `tenant.role.read.live` then
|
||||||
`flex-auth` behavior, not assumed. This is correct fail-closed behavior,
|
reads the store. That check does **not** re-enter tenant-engine:
|
||||||
not a bug: tenant-engine cannot perform any write against a real `flex-auth`
|
|
||||||
deployment until that policy package exists. Verified locally with a fake
|
1. The CheckRequest this engine sends has empty `context` and does not
|
||||||
HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`,
|
carry `tenant_roles`.
|
||||||
both over real HTTP between two processes) — see `TEN-WP-0003`'s closure
|
2. `FlexAuthCheckClient` POSTs only `/v1/check`. Proven by
|
||||||
notes for the exact commands.
|
`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
|
## Configuration
|
||||||
|
|
||||||
| Env var | Default | Meaning |
|
| 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` — no writes ever succeed, which is the correct posture for local/test runs that have no `flex-auth` to call. |
|
| `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 write path — no retries, so a slow deny doesn't become a hang. |
|
| `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. |
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
- `pep-stance.yaml` — published fail-closed map
|
||||||
|
- `pip-claims.yaml` — input-class freshness
|
||||||
- `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
|
- `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md`
|
||||||
— the flex-auth-side work this client depends on for real `allow`
|
- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md`
|
||||||
decisions, and (separately) flex-auth's own live-lookup consumption of
|
- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`
|
||||||
`tenant-engine`'s `/roles/live` endpoint for other protected systems.
|
|
||||||
- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md` — the
|
|
||||||
cache-read/`tenant_roles` direction (`key-cape` → `tenant-engine`), not
|
|
||||||
covered by this doc.
|
|
||||||
- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` —
|
|
||||||
the Authorization Contract section this client implements.
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ kind: intake
|
||||||
title: "Externalize tenant-engine audit evidence to audit-core"
|
title: "Externalize tenant-engine audit evidence to audit-core"
|
||||||
lane: yellow
|
lane: yellow
|
||||||
status: closed
|
status: closed
|
||||||
outcome: promoted
|
outcome: completed
|
||||||
promoted_to: TEN-WP-0011-T04
|
promoted_to: TEN-WP-0011-T04
|
||||||
priority: high
|
priority: high
|
||||||
owner: tenant-engine
|
owner: tenant-engine
|
||||||
|
|
@ -58,7 +58,7 @@ kind: intake
|
||||||
title: "Remove or authorize the tenant-engine unfiltered event-read interface"
|
title: "Remove or authorize the tenant-engine unfiltered event-read interface"
|
||||||
lane: red
|
lane: red
|
||||||
status: closed
|
status: closed
|
||||||
outcome: promoted
|
outcome: completed
|
||||||
promoted_to: TEN-WP-0011-T05
|
promoted_to: TEN-WP-0011-T05
|
||||||
priority: high
|
priority: high
|
||||||
owner: tenant-engine
|
owner: tenant-engine
|
||||||
|
|
|
||||||
84
layer.yaml
Normal file
84
layer.yaml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# tenant-engine — NetKingdom security layer declaration
|
||||||
|
#
|
||||||
|
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md
|
||||||
|
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2
|
||||||
|
# Assent: decisions/decisions.md TEN-DEC-2026-001
|
||||||
|
# Validate: python3 scripts/check_layer_conformance.py
|
||||||
|
#
|
||||||
|
# Engine / PIP. Same authoritative tenant state yields the same result. We
|
||||||
|
# supply tenant-as-an-entity facts as claims; we do not render a decision.
|
||||||
|
# Writes are PEP-shaped (pep-stance.yaml). Catalogued Tooling (§4) is
|
||||||
|
# key-cape and OpenBao — we hold no client for either.
|
||||||
|
|
||||||
|
schema_version: "0.1"
|
||||||
|
framework: netkingdom-security-layer-model
|
||||||
|
standard_version: "0.7"
|
||||||
|
repository: tenant-engine
|
||||||
|
layer: engine
|
||||||
|
role: pip
|
||||||
|
declared_by: decisions/decisions.md#TEN-DEC-2026-001
|
||||||
|
declared_at: "2026-08-29"
|
||||||
|
|
||||||
|
pep_stance: pep-stance.yaml
|
||||||
|
pip_claims: pip-claims.yaml
|
||||||
|
|
||||||
|
catalog_entry:
|
||||||
|
owns:
|
||||||
|
- tenant-as-an-entity facts
|
||||||
|
|
||||||
|
# Empty is a claim. scripts/check_layer_conformance.py fails a new OpenBao
|
||||||
|
# or key-cape client that is not listed here.
|
||||||
|
tooling_contacts: []
|
||||||
|
|
||||||
|
declared_shapes:
|
||||||
|
"5.1": []
|
||||||
|
"5.2": []
|
||||||
|
"5.3": []
|
||||||
|
|
||||||
|
non_tooling_clients:
|
||||||
|
- id: postgres-own-store
|
||||||
|
module: src/tenant_engine/postgres_store.py
|
||||||
|
target: PostgreSQL
|
||||||
|
layer: not-catalogued
|
||||||
|
operation: "psycopg pool against TENANT_ENGINE_DATABASE_URL_FILE"
|
||||||
|
write: true
|
||||||
|
note: >-
|
||||||
|
Persistence this engine owns for its own facts. Companion §4: list
|
||||||
|
uncatalogued infrastructure so the check is total. Not a Tooling
|
||||||
|
contact — OpenBao and key-cape are the §4 Tooling rows.
|
||||||
|
|
||||||
|
- id: sqlite-dev-store
|
||||||
|
module: src/tenant_engine/sqlite_store.py
|
||||||
|
target: SQLite
|
||||||
|
layer: not-catalogued
|
||||||
|
operation: "sqlite3 file used for development and test"
|
||||||
|
write: true
|
||||||
|
note: "Dev/test backend. Production is PostgreSQL (TEN-WP-0009)."
|
||||||
|
|
||||||
|
- id: access-engine-check
|
||||||
|
module: src/tenant_engine/flex_auth.py
|
||||||
|
target: access-engine (flex-auth)
|
||||||
|
layer: engine
|
||||||
|
operation: "HTTP POST /v1/check"
|
||||||
|
write: false
|
||||||
|
note: "Engine API. §5 permits it; this is the shape §5 prescribes."
|
||||||
|
|
||||||
|
- id: audit-core-emission
|
||||||
|
module: src/tenant_engine/audit_core.py
|
||||||
|
target: audit-core
|
||||||
|
layer: engine
|
||||||
|
operation: "HTTP POST /v1/events from the local outbox"
|
||||||
|
write: true
|
||||||
|
note: >-
|
||||||
|
Evidence engine. Emission is attributive and non-blocking
|
||||||
|
(docs/evidence-emission.md). Sender registration on audit-core is
|
||||||
|
requested separately.
|
||||||
|
|
||||||
|
- id: state-hub-work-records
|
||||||
|
target: state-hub
|
||||||
|
layer: not-catalogued
|
||||||
|
operation: "HTTP to the Custodian State Hub for work records"
|
||||||
|
write: true
|
||||||
|
note: >-
|
||||||
|
Outside §5 by the v0.7 scope rule. Recorded, not policed. Carries no
|
||||||
|
tenant-fact authority and no secret payload.
|
||||||
33
migrations/postgres/0002_authz_and_outbox.sql
Normal file
33
migrations/postgres/0002_authz_and_outbox.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS authz_records (
|
||||||
|
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
allowed BOOLEAN NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
at TIMESTAMPTZ NOT NULL,
|
||||||
|
decision_id TEXT,
|
||||||
|
request_digest TEXT,
|
||||||
|
effect TEXT,
|
||||||
|
stance TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS authz_records_tenant_seq_idx
|
||||||
|
ON authz_records (tenant_id, seq);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_outbox (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
envelope JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
dead_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_outbox_pending_idx
|
||||||
|
ON audit_outbox (created_at) WHERE delivered_at IS NULL AND dead_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
50
pep-stance.yaml
Normal file
50
pep-stance.yaml
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# tenant-engine — PEP unreachable-engine stance map
|
||||||
|
#
|
||||||
|
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §6.4, §9.3
|
||||||
|
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2 §5
|
||||||
|
# Named from: layer.yaml (pep_stance)
|
||||||
|
# Validate: tests/test_layer_conformance.py
|
||||||
|
#
|
||||||
|
# tenant-engine's protected side effect is a mutation of tenant-as-an-entity
|
||||||
|
# facts (create, grant, revoke, plan, lifecycle, grouping, guardrail).
|
||||||
|
# This is not ops-warden's per-zone fail-open map. A PIP that cannot ask
|
||||||
|
# the PDP does not guess; it refuses.
|
||||||
|
|
||||||
|
schema_version: "0.1"
|
||||||
|
framework: netkingdom-security-layer-model
|
||||||
|
standard_version: "0.7"
|
||||||
|
repository: tenant-engine
|
||||||
|
pep_shape: true
|
||||||
|
declared_by: decisions/decisions.md#TEN-DEC-2026-001
|
||||||
|
|
||||||
|
protected_action: "mutation of tenant-as-an-entity facts"
|
||||||
|
decision_engine: access-engine
|
||||||
|
scope: engine-reachability
|
||||||
|
|
||||||
|
# Total by construction. No implicit default — an unlisted value is a
|
||||||
|
# config error, not a permissive fallback. Every scope fails closed.
|
||||||
|
stance:
|
||||||
|
unset: fail_closed # TENANT_ENGINE_FLEX_AUTH_URL is not set
|
||||||
|
unreachable: fail_closed # transport, timeout, non-2xx, malformed body
|
||||||
|
non_allow: fail_closed # a decision was rendered, effect was not allow
|
||||||
|
unknown: fail_closed
|
||||||
|
|
||||||
|
on_apply:
|
||||||
|
recorded_fields:
|
||||||
|
- authorization_source # decision | stance
|
||||||
|
- authorization_decision_id
|
||||||
|
- authorization_request_digest
|
||||||
|
- authorization_effect
|
||||||
|
- authorization_stance
|
||||||
|
- authorization_reason
|
||||||
|
- action
|
||||||
|
- tenant_id
|
||||||
|
- actor
|
||||||
|
- allowed
|
||||||
|
written_to:
|
||||||
|
- "authz_records (every authorize attempt)"
|
||||||
|
- "events.payload (successful mutations only)"
|
||||||
|
never_recorded: "tokens, secrets, request bodies beyond the check digest"
|
||||||
|
|
||||||
|
# §6.4 obligation 2 — the verdict is never cached. Every write re-checks.
|
||||||
|
verdict_caching: none
|
||||||
80
pip-claims.yaml
Normal file
80
pip-claims.yaml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# tenant-engine — PIP claim freshness contract
|
||||||
|
#
|
||||||
|
# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §9.3, §9.7
|
||||||
|
# Companion: net-kingdom/SECURITY-COMPANION.md v0.2
|
||||||
|
# Named from: layer.yaml (pip_claims)
|
||||||
|
# Validate: tests/test_pip_claims.py
|
||||||
|
#
|
||||||
|
# access-engine states a deadline per input class. This file is the input
|
||||||
|
# class description it is owed for tenant facts.
|
||||||
|
|
||||||
|
schema_version: "0.1"
|
||||||
|
framework: netkingdom-security-layer-model
|
||||||
|
standard_version: "0.7"
|
||||||
|
repository: tenant-engine
|
||||||
|
role: pip
|
||||||
|
|
||||||
|
input_classes:
|
||||||
|
|
||||||
|
tenant_roles_cached:
|
||||||
|
surface: "GET /tenants/{id}/roles"
|
||||||
|
consumer: key-cape
|
||||||
|
carrying: "IAM Profile v0.3 optional tenant_roles token claim"
|
||||||
|
lifetime: "the issuing token's lifetime; this engine does not push-invalidate"
|
||||||
|
cross_request_cache_by_this_engine: false
|
||||||
|
notes: >-
|
||||||
|
The token claim is a point-in-time copy. Privileged decisions MUST
|
||||||
|
not trust it; they use tenant_roles_live.
|
||||||
|
|
||||||
|
tenant_roles_live:
|
||||||
|
surface: "GET /tenants/{id}/roles/live"
|
||||||
|
consumer: access-engine
|
||||||
|
carrying: "current active capability roles"
|
||||||
|
lifetime: "this response only"
|
||||||
|
cross_request_cache_by_consumer: false
|
||||||
|
request_scoped_memoization: true
|
||||||
|
notes: >-
|
||||||
|
Boundary contract: request-scoped memoization inside one decision is
|
||||||
|
allowed. Cross-request caching of live-lookup results is not — that
|
||||||
|
would recreate the staleness the live path exists to avoid (§6.1).
|
||||||
|
|
||||||
|
tenant_record:
|
||||||
|
surface: "GET /tenants/{id}"
|
||||||
|
consumer: user-engine operator UI and other admin surfaces
|
||||||
|
carrying: "existence, grouping, lifecycle, metadata, version ETag"
|
||||||
|
lifetime: "until the ETag changes"
|
||||||
|
cross_request_cache_by_this_engine: false
|
||||||
|
|
||||||
|
tenant_guardrails:
|
||||||
|
surface: "GET /tenants/{id}/guardrails"
|
||||||
|
consumer: access-engine
|
||||||
|
carrying: "effective ceilings per registered limit key"
|
||||||
|
lifetime: "this response only"
|
||||||
|
cross_request_cache_by_consumer: false
|
||||||
|
request_scoped_memoization: true
|
||||||
|
notes: >-
|
||||||
|
A ceiling is a claim, never an allow. Unavailability must not be
|
||||||
|
read as "no limits".
|
||||||
|
|
||||||
|
degradation:
|
||||||
|
store_unavailable:
|
||||||
|
http_status: 503
|
||||||
|
body_never: "200 with empty roles or empty limits"
|
||||||
|
consumer_meaning: "input degradation at access-engine (§9.3); fail to reduced authority"
|
||||||
|
surfaces:
|
||||||
|
- tenant_roles_cached
|
||||||
|
- tenant_roles_live
|
||||||
|
- tenant_record
|
||||||
|
- tenant_guardrails
|
||||||
|
|
||||||
|
live_lookup_authorization:
|
||||||
|
action: tenant.role.read.live
|
||||||
|
check_consumes_tenant_roles: false
|
||||||
|
reenters_tenant_engine: false
|
||||||
|
evidence: docs/flex-auth-integration.md
|
||||||
|
notes: >-
|
||||||
|
The check is a service-identity question (who may call this PIP), not
|
||||||
|
a tenant-capability-role question. flex-auth's tenant-engine policy
|
||||||
|
package matches subject id and action only and does not consult
|
||||||
|
tenant_roles. Its live-roles adapter is built and unwired. Therefore
|
||||||
|
GET /roles/live → POST /v1/check does not re-enter this engine.
|
||||||
|
|
@ -24,6 +24,7 @@ postgres = [
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.2,<9.0",
|
"pytest>=8.2,<9.0",
|
||||||
"ruff>=0.6,<1.0",
|
"ruff>=0.6,<1.0",
|
||||||
|
"pyyaml>=6.0,<7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
149
scripts/check_layer_conformance.py
Normal file
149
scripts/check_layer_conformance.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check tenant-engine against the NetKingdom security layer model (§5, §11).
|
||||||
|
|
||||||
|
Read-only. Makes two mechanical checks:
|
||||||
|
|
||||||
|
1. INTENT.md frontmatter and layer.yaml agree on layer and role.
|
||||||
|
2. No catalogued Tooling client (OpenBao, key-cape) appears in src/
|
||||||
|
unless it maps to a declared §5.1 / §5.2 / §5.3 entry.
|
||||||
|
|
||||||
|
PostgreSQL / SQLite / httpx-to-flex-auth / httpx-to-audit-core are not
|
||||||
|
Tooling contacts. They are listed in layer.yaml non_tooling_clients so
|
||||||
|
the inventory is total.
|
||||||
|
|
||||||
|
Exit 0 clean, 1 undeclared contact, 2 declaration malformed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover - dev extra
|
||||||
|
print("FAIL: PyYAML is required (pip install pyyaml)", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "src" / "tenant_engine"
|
||||||
|
DECL = ROOT / "layer.yaml"
|
||||||
|
INTENT = ROOT / "INTENT.md"
|
||||||
|
|
||||||
|
# Catalogued Tooling in statute §4 today: key-cape and OpenBao.
|
||||||
|
# Import roots that would constitute a direct client of those.
|
||||||
|
TOOLING_IMPORTS = {
|
||||||
|
"hvac": "OpenBao / Vault client",
|
||||||
|
"bao": "OpenBao client",
|
||||||
|
"keycloak": "key-cape / Keycloak client",
|
||||||
|
"ldap3": "direct LDAP client (key-cape tooling)",
|
||||||
|
"python_ldap": "direct LDAP client (key-cape tooling)",
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOLING_ARGV = re.compile(r"""\[\s*(?:["']bao["']|bao_bin\b|bao_binary\b)\s*,""")
|
||||||
|
OPENBAO_ADDR = re.compile(r"\b(?:VAULT_ADDR|BAO_ADDR|X-Vault-Token)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def load_declaration() -> dict:
|
||||||
|
if not DECL.exists():
|
||||||
|
print(f"FAIL: no declaration at {DECL.relative_to(ROOT)} (§11)", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(DECL.read_text())
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
print(f"FAIL: {DECL.name} is not parseable: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(2) from exc
|
||||||
|
for key in ("layer", "role", "repository", "standard_version", "tooling_contacts"):
|
||||||
|
if key not in data:
|
||||||
|
print(f"FAIL: {DECL.name} missing required key '{key}'", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
if str(data["layer"]).lower() != "engine":
|
||||||
|
print(f"FAIL: declared layer is {data['layer']!r}, expected engine", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
if str(data["role"]).lower() != "pip":
|
||||||
|
print(f"FAIL: declared role is {data['role']!r}, expected pip", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
if data["repository"] != "tenant-engine":
|
||||||
|
print(f"FAIL: repository is {data['repository']!r}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def intent_frontmatter() -> dict:
|
||||||
|
text = INTENT.read_text()
|
||||||
|
if not text.startswith("---"):
|
||||||
|
print("FAIL: INTENT.md has no YAML frontmatter", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
block = text.split("---", 2)[1]
|
||||||
|
data = yaml.safe_load(block) or {}
|
||||||
|
if str(data.get("layer", "")).lower() != "engine":
|
||||||
|
print(f"FAIL: INTENT.md layer is {data.get('layer')!r}, expected Engine", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
if str(data.get("role", "")).lower() != "pip":
|
||||||
|
print(f"FAIL: INTENT.md role is {data.get('role')!r}, expected PIP", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def imported_modules(path: Path) -> set[str]:
|
||||||
|
try:
|
||||||
|
tree = ast.parse(path.read_text())
|
||||||
|
except SyntaxError:
|
||||||
|
return set()
|
||||||
|
found: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||||
|
found.add(node.module.split(".")[0])
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def scan() -> list[tuple[Path, str, str]]:
|
||||||
|
hits: list[tuple[Path, str, str]] = []
|
||||||
|
for path in sorted(SRC.rglob("*.py")):
|
||||||
|
text = path.read_text()
|
||||||
|
for module in sorted(imported_modules(path)):
|
||||||
|
if module in TOOLING_IMPORTS:
|
||||||
|
hits.append((path, module, TOOLING_IMPORTS[module]))
|
||||||
|
if TOOLING_ARGV.search(text) or OPENBAO_ADDR.search(text):
|
||||||
|
hits.append((path, "openbao-invocation", "OpenBao argv or address"))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--report", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
decl = load_declaration()
|
||||||
|
intent_frontmatter()
|
||||||
|
hits = scan()
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
print(f"tenant-engine — layer {decl['layer']}, role {decl['role']}, "
|
||||||
|
f"standard v{decl['standard_version']}")
|
||||||
|
print(f" tooling contacts declared: {len(decl.get('tooling_contacts') or [])}")
|
||||||
|
print(f" non-tooling clients: {len(decl.get('non_tooling_clients') or [])}")
|
||||||
|
|
||||||
|
if hits:
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print("FAIL: undeclared Tooling-layer client (§11 undeclared violation)", file=sys.stderr)
|
||||||
|
for path, module, what in hits:
|
||||||
|
print(f" {path.relative_to(ROOT)}: {module} — {what}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if decl.get("tooling_contacts"):
|
||||||
|
print("FAIL: tooling_contacts is not empty; this engine claimed none", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not args.report:
|
||||||
|
print(f"OK: Engine/PIP declaration matches INTENT.md; no Tooling client in "
|
||||||
|
f"{SRC.relative_to(ROOT)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -9,7 +9,9 @@ from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from tenant_engine import __version__
|
from tenant_engine import __version__
|
||||||
|
from tenant_engine.audit_core import AuditCoreClient
|
||||||
from tenant_engine.authz import (
|
from tenant_engine.authz import (
|
||||||
|
AuthorizationOutcome,
|
||||||
DefaultDenyWriteAuthorizer,
|
DefaultDenyWriteAuthorizer,
|
||||||
FlexAuthWriteAuthorizer,
|
FlexAuthWriteAuthorizer,
|
||||||
WriteAuthorizationDeniedError,
|
WriteAuthorizationDeniedError,
|
||||||
|
|
@ -44,6 +46,7 @@ from tenant_engine.guardrail import (
|
||||||
)
|
)
|
||||||
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
|
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
|
||||||
from tenant_engine.store import (
|
from tenant_engine.store import (
|
||||||
|
AuthorizationRecord,
|
||||||
GrantNotFoundError,
|
GrantNotFoundError,
|
||||||
IdempotencyConflictError,
|
IdempotencyConflictError,
|
||||||
InMemoryTenantStore,
|
InMemoryTenantStore,
|
||||||
|
|
@ -185,9 +188,11 @@ def create_app(
|
||||||
app = FastAPI(title="tenant-engine", version=__version__)
|
app = FastAPI(title="tenant-engine", version=__version__)
|
||||||
app.state.store = store
|
app.state.store = store
|
||||||
app.state.authorizer = authorizer
|
app.state.authorizer = authorizer
|
||||||
|
app.state.audit_core = _build_audit_client(settings)
|
||||||
|
|
||||||
@app.exception_handler(WriteAuthorizationDeniedError)
|
@app.exception_handler(WriteAuthorizationDeniedError)
|
||||||
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
|
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
|
||||||
|
_persist_authz(store, exc.outcome)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
|
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
|
||||||
|
|
@ -233,7 +238,7 @@ def create_app(
|
||||||
response: Response,
|
response: Response,
|
||||||
actor: str = Query(min_length=1),
|
actor: str = Query(min_length=1),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
authorizer.authorize(action="tenant.read", tenant_id=tenant_id, actor=actor)
|
_authorize(authorizer, store, action="tenant.read", tenant_id=tenant_id, actor=actor)
|
||||||
try:
|
try:
|
||||||
tenant = store.get_tenant(tenant_id)
|
tenant = store.get_tenant(tenant_id)
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
|
|
@ -249,7 +254,7 @@ def create_app(
|
||||||
|
|
||||||
@app.get("/tenants/{tenant_id}/roles")
|
@app.get("/tenants/{tenant_id}/roles")
|
||||||
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
async def cache_read_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
||||||
authorizer.authorize(action="tenant.role.read", tenant_id=tenant_id, actor=actor)
|
_authorize(authorizer, store, action="tenant.role.read", tenant_id=tenant_id, actor=actor)
|
||||||
return _read_roles(store, tenant_id)
|
return _read_roles(store, tenant_id)
|
||||||
|
|
||||||
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
||||||
|
|
@ -261,7 +266,9 @@ def create_app(
|
||||||
|
|
||||||
@app.get("/tenants/{tenant_id}/roles/live")
|
@app.get("/tenants/{tenant_id}/roles/live")
|
||||||
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
async def live_lookup_roles(tenant_id: str, actor: str = Query(min_length=1)) -> dict:
|
||||||
authorizer.authorize(action="tenant.role.read.live", tenant_id=tenant_id, actor=actor)
|
_authorize(
|
||||||
|
authorizer, store, action="tenant.role.read.live", tenant_id=tenant_id, actor=actor
|
||||||
|
)
|
||||||
return _read_roles(store, tenant_id)
|
return _read_roles(store, tenant_id)
|
||||||
|
|
||||||
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
||||||
|
|
@ -270,7 +277,13 @@ def create_app(
|
||||||
|
|
||||||
@app.post("/tenants", status_code=201)
|
@app.post("/tenants", status_code=201)
|
||||||
async def create_tenant(payload: CreateTenantRequest) -> dict:
|
async def create_tenant(payload: CreateTenantRequest) -> dict:
|
||||||
authorizer.authorize(action="tenant.create", tenant_id=payload.tenant_id, actor=payload.actor)
|
outcome = _authorize(
|
||||||
|
authorizer,
|
||||||
|
store,
|
||||||
|
action="tenant.create",
|
||||||
|
tenant_id=payload.tenant_id,
|
||||||
|
actor=payload.actor,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
tenant = Tenant.create(
|
tenant = Tenant.create(
|
||||||
tenant_id=payload.tenant_id,
|
tenant_id=payload.tenant_id,
|
||||||
|
|
@ -279,7 +292,8 @@ def create_app(
|
||||||
contact_email=payload.contact_email,
|
contact_email=payload.contact_email,
|
||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
store.create_tenant(tenant)
|
store.create_tenant(tenant, authz=outcome.as_payload())
|
||||||
|
_drain_outbox(store, app.state.audit_core)
|
||||||
except InvalidTenantIdentifierError as exc:
|
except InvalidTenantIdentifierError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except TenantAlreadyExistsError as exc:
|
except TenantAlreadyExistsError as exc:
|
||||||
|
|
@ -290,7 +304,9 @@ def create_app(
|
||||||
|
|
||||||
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
|
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
|
||||||
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
|
async def grant_role(tenant_id: str, payload: GrantRoleRequest) -> dict:
|
||||||
authorizer.authorize(action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor)
|
outcome = _authorize(
|
||||||
|
authorizer, store, action="tenant.role.grant", tenant_id=tenant_id, actor=payload.actor
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
tenant = store.get_tenant(tenant_id)
|
tenant = store.get_tenant(tenant_id)
|
||||||
grant = create_role_grant(
|
grant = create_role_grant(
|
||||||
|
|
@ -303,7 +319,8 @@ def create_app(
|
||||||
correlation_id=payload.correlation_id,
|
correlation_id=payload.correlation_id,
|
||||||
granted_at=datetime.now(UTC),
|
granted_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
store.grant_role(grant)
|
store.grant_role(grant, authz=outcome.as_payload())
|
||||||
|
_drain_outbox(store, app.state.audit_core)
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||||
except TenantRetiredError as exc:
|
except TenantRetiredError as exc:
|
||||||
|
|
@ -314,9 +331,17 @@ def create_app(
|
||||||
|
|
||||||
@app.post("/tenants/{tenant_id}/roles/revoke")
|
@app.post("/tenants/{tenant_id}/roles/revoke")
|
||||||
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict:
|
async def revoke_role(tenant_id: str, payload: RevokeRoleRequest) -> dict:
|
||||||
authorizer.authorize(action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor)
|
outcome = _authorize(
|
||||||
|
authorizer, store, action="tenant.role.revoke", tenant_id=tenant_id, actor=payload.actor
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
revoked = store.revoke_role(tenant_id=tenant_id, grant_id=payload.grant_id, at=datetime.now(UTC))
|
revoked = store.revoke_role(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
grant_id=payload.grant_id,
|
||||||
|
at=datetime.now(UTC),
|
||||||
|
authz=outcome.as_payload(),
|
||||||
|
)
|
||||||
|
_drain_outbox(store, app.state.audit_core)
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||||
except GrantNotFoundError as exc:
|
except GrantNotFoundError as exc:
|
||||||
|
|
@ -327,11 +352,17 @@ def create_app(
|
||||||
|
|
||||||
@app.post("/tenants/{tenant_id}/plan")
|
@app.post("/tenants/{tenant_id}/plan")
|
||||||
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict:
|
async def assign_plan(tenant_id: str, payload: AssignPlanRequest) -> dict:
|
||||||
authorizer.authorize(action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor)
|
outcome = _authorize(
|
||||||
|
authorizer, store, action="tenant.plan.assign", tenant_id=tenant_id, actor=payload.actor
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
store.assign_plan(
|
store.assign_plan(
|
||||||
PlanAssignment(tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC))
|
PlanAssignment(
|
||||||
|
tenant_id=tenant_id, plan_id=payload.plan_id, assigned_at=datetime.now(UTC)
|
||||||
|
),
|
||||||
|
authz=outcome.as_payload(),
|
||||||
)
|
)
|
||||||
|
_drain_outbox(store, app.state.audit_core)
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||||
except TenantRetiredError as exc:
|
except TenantRetiredError as exc:
|
||||||
|
|
@ -366,6 +397,7 @@ def create_app(
|
||||||
event_type="tenant_updated",
|
event_type="tenant_updated",
|
||||||
extra_fingerprint=changes,
|
extra_fingerprint=changes,
|
||||||
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
|
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/tenants/{tenant_id}/retire")
|
@app.post("/tenants/{tenant_id}/retire")
|
||||||
|
|
@ -390,6 +422,7 @@ def create_app(
|
||||||
event_type="tenant_retired",
|
event_type="tenant_retired",
|
||||||
extra_fingerprint={},
|
extra_fingerprint={},
|
||||||
mutate=lambda tenant, at: tenant.retire(at=at),
|
mutate=lambda tenant, at: tenant.retire(at=at),
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/tenants/{tenant_id}/reactivate")
|
@app.post("/tenants/{tenant_id}/reactivate")
|
||||||
|
|
@ -414,6 +447,7 @@ def create_app(
|
||||||
event_type="tenant_reactivated",
|
event_type="tenant_reactivated",
|
||||||
extra_fingerprint={},
|
extra_fingerprint={},
|
||||||
mutate=lambda tenant, at: tenant.reactivate(at=at),
|
mutate=lambda tenant, at: tenant.reactivate(at=at),
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Reclassification (TEN-WP-0010) -----------------------------------
|
# -- Reclassification (TEN-WP-0010) -----------------------------------
|
||||||
|
|
@ -443,6 +477,7 @@ def create_app(
|
||||||
event_type="tenant_grouping_changed",
|
event_type="tenant_grouping_changed",
|
||||||
extra_fingerprint={"grouping": payload.grouping},
|
extra_fingerprint={"grouping": payload.grouping},
|
||||||
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
|
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Guardrail API (TEN-WP-0006) --------------------------------------
|
# -- Guardrail API (TEN-WP-0006) --------------------------------------
|
||||||
|
|
@ -451,7 +486,9 @@ def create_app(
|
||||||
|
|
||||||
@app.get("/tenants/{tenant_id}/guardrails")
|
@app.get("/tenants/{tenant_id}/guardrails")
|
||||||
async def read_guardrails(tenant_id: str, actor: str) -> dict:
|
async def read_guardrails(tenant_id: str, actor: str) -> dict:
|
||||||
authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor)
|
_authorize(
|
||||||
|
authorizer, store, action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
tenant = store.get_tenant(tenant_id)
|
tenant = store.get_tenant(tenant_id)
|
||||||
overrides = store.guardrail_overrides(tenant_id)
|
overrides = store.guardrail_overrides(tenant_id)
|
||||||
|
|
@ -487,9 +524,7 @@ def create_app(
|
||||||
try:
|
try:
|
||||||
value = load_limit(payload.limit.model_dump())
|
value = load_limit(payload.limit.model_dump())
|
||||||
except (InvalidLimitError, ValueError) as exc:
|
except (InvalidLimitError, ValueError) as exc:
|
||||||
raise LifecycleError(
|
raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc
|
||||||
400, "invalid_limit", str(exc), payload.correlation_id
|
|
||||||
) from exc
|
|
||||||
return _guardrail_mutation(
|
return _guardrail_mutation(
|
||||||
store=store,
|
store=store,
|
||||||
authorizer=authorizer,
|
authorizer=authorizer,
|
||||||
|
|
@ -502,6 +537,7 @@ def create_app(
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
if_match=if_match,
|
if_match=if_match,
|
||||||
response=response,
|
response=response,
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
|
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
|
||||||
|
|
@ -525,6 +561,7 @@ def create_app(
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
if_match=if_match,
|
if_match=if_match,
|
||||||
response=response,
|
response=response,
|
||||||
|
audit_core=app.state.audit_core,
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
@ -553,6 +590,7 @@ def _guardrail_mutation(
|
||||||
idempotency_key: str | None,
|
idempotency_key: str | None,
|
||||||
if_match: str | None,
|
if_match: str | None,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
audit_core: AuditCoreClient | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared spine for setting and clearing an override.
|
"""Shared spine for setting and clearing an override.
|
||||||
|
|
||||||
|
|
@ -566,7 +604,9 @@ def _guardrail_mutation(
|
||||||
)
|
)
|
||||||
expected_version = _parse_if_match(if_match, correlation_id)
|
expected_version = _parse_if_match(if_match, correlation_id)
|
||||||
|
|
||||||
authorizer.authorize(action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor)
|
outcome = _authorize(
|
||||||
|
authorizer, store, action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
fingerprint = hashlib.sha256(
|
fingerprint = hashlib.sha256(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|
@ -585,9 +625,9 @@ def _guardrail_mutation(
|
||||||
# Derived, not random: a genuine retry must produce the same change_id so
|
# Derived, not random: a genuine retry must produce the same change_id so
|
||||||
# the replay path returns the original audit record rather than minting a
|
# the replay path returns the original audit record rather than minting a
|
||||||
# second one for a mutation that happened once.
|
# second one for a mutation that happened once.
|
||||||
change_id = hashlib.sha256(
|
change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[
|
||||||
f"{tenant_id}:{limit_key}:{idempotency_key}".encode()
|
:32
|
||||||
).hexdigest()[:32]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tenant, change, replayed = store.set_guardrail_override(
|
tenant, change, replayed = store.set_guardrail_override(
|
||||||
|
|
@ -602,7 +642,9 @@ def _guardrail_mutation(
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
request_fingerprint=fingerprint,
|
request_fingerprint=fingerprint,
|
||||||
at=datetime.now(UTC),
|
at=datetime.now(UTC),
|
||||||
|
authz=outcome.as_payload(),
|
||||||
)
|
)
|
||||||
|
_drain_outbox(store, audit_core)
|
||||||
except UnknownLimitKeyError as exc:
|
except UnknownLimitKeyError as exc:
|
||||||
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
|
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
|
|
@ -622,9 +664,7 @@ def _guardrail_mutation(
|
||||||
correlation_id,
|
correlation_id,
|
||||||
) from exc
|
) from exc
|
||||||
except TenantRetiredError as exc:
|
except TenantRetiredError as exc:
|
||||||
raise LifecycleError(
|
raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc
|
||||||
409, "guardrail_loosening_denied", str(exc), correlation_id
|
|
||||||
) from exc
|
|
||||||
except (InvalidLimitError, ConflictingLimitError) as exc:
|
except (InvalidLimitError, ConflictingLimitError) as exc:
|
||||||
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
|
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
|
||||||
except StoreUnavailableError as exc:
|
except StoreUnavailableError as exc:
|
||||||
|
|
@ -704,6 +744,7 @@ def _lifecycle_mutation(
|
||||||
event_type: str,
|
event_type: str,
|
||||||
extra_fingerprint: dict,
|
extra_fingerprint: dict,
|
||||||
mutate,
|
mutate,
|
||||||
|
audit_core: AuditCoreClient | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared spine for update/retire/reactivate: authorize, then CAS.
|
"""Shared spine for update/retire/reactivate: authorize, then CAS.
|
||||||
|
|
||||||
|
|
@ -716,7 +757,7 @@ def _lifecycle_mutation(
|
||||||
)
|
)
|
||||||
expected_version = _parse_if_match(if_match, correlation_id)
|
expected_version = _parse_if_match(if_match, correlation_id)
|
||||||
|
|
||||||
authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
|
outcome = _authorize(authorizer, store, action=action, tenant_id=tenant_id, actor=actor)
|
||||||
|
|
||||||
fingerprint = hashlib.sha256(
|
fingerprint = hashlib.sha256(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|
@ -738,10 +779,16 @@ def _lifecycle_mutation(
|
||||||
expected_version=expected_version,
|
expected_version=expected_version,
|
||||||
mutate=lambda current: mutate(current, now),
|
mutate=lambda current: mutate(current, now),
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
evidence={"actor": actor, "reason": reason, "correlation_id": correlation_id},
|
evidence={
|
||||||
|
"actor": actor,
|
||||||
|
"reason": reason,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
**outcome.as_payload(),
|
||||||
|
},
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
request_fingerprint=fingerprint,
|
request_fingerprint=fingerprint,
|
||||||
)
|
)
|
||||||
|
_drain_outbox(store, audit_core)
|
||||||
except TenantNotFoundError as exc:
|
except TenantNotFoundError as exc:
|
||||||
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
|
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
|
||||||
except IdempotencyConflictError as exc:
|
except IdempotencyConflictError as exc:
|
||||||
|
|
@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer:
|
||||||
return FlexAuthWriteAuthorizer(client=client)
|
return FlexAuthWriteAuthorizer(client=client)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_audit_client(settings: Settings) -> AuditCoreClient | None:
|
||||||
|
if not settings.audit_core_base_url:
|
||||||
|
return None
|
||||||
|
return AuditCoreClient(
|
||||||
|
base_url=settings.audit_core_base_url,
|
||||||
|
timeout_seconds=settings.audit_core_timeout_seconds,
|
||||||
|
token_file=settings.audit_core_token_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
authorizer: WriteAuthorizer,
|
||||||
|
store: TenantStore,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
tenant_id: str,
|
||||||
|
actor: str,
|
||||||
|
) -> AuthorizationOutcome:
|
||||||
|
try:
|
||||||
|
outcome = authorizer.authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||||
|
except WriteAuthorizationDeniedError:
|
||||||
|
raise
|
||||||
|
_persist_authz(store, outcome)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_authz(store: TenantStore, outcome: AuthorizationOutcome) -> None:
|
||||||
|
recorder = getattr(store, "record_authorization", None)
|
||||||
|
if recorder is None:
|
||||||
|
return
|
||||||
|
recorder(
|
||||||
|
AuthorizationRecord(
|
||||||
|
action=outcome.action,
|
||||||
|
tenant_id=outcome.tenant_id,
|
||||||
|
actor=outcome.actor,
|
||||||
|
allowed=outcome.allowed,
|
||||||
|
source=outcome.source,
|
||||||
|
reason=outcome.reason,
|
||||||
|
at=datetime.now(UTC),
|
||||||
|
decision_id=outcome.decision_id,
|
||||||
|
request_digest=outcome.request_digest,
|
||||||
|
effect=outcome.effect,
|
||||||
|
stance=outcome.stance,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_outbox(store: TenantStore, client: AuditCoreClient | None) -> None:
|
||||||
|
"""Best-effort drain. Never fails the mutation (attributive, non-blocking)."""
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
pending = getattr(store, "pending_outbox", None)
|
||||||
|
mark = getattr(store, "mark_outbox", None)
|
||||||
|
if pending is None or mark is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for row in pending():
|
||||||
|
result = client.post_event(row.envelope)
|
||||||
|
mark(row.event_id, status=result.status, detail=result.detail)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
|
def _read_roles(store: TenantStore, tenant_id: str) -> dict:
|
||||||
try:
|
try:
|
||||||
roles = store.active_roles(tenant_id)
|
roles = store.active_roles(tenant_id)
|
||||||
|
|
|
||||||
106
src/tenant_engine/audit_core.py
Normal file
106
src/tenant_engine/audit_core.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""Attributive emission to audit-core from the local outbox.
|
||||||
|
|
||||||
|
Trade (statute §9.6): mutation evidence is attributive. Emission is
|
||||||
|
atomic with the local outbox (crash between mutation and insert is
|
||||||
|
prevented). Drain to audit-core is after commit and MUST NOT fail a
|
||||||
|
mutation. Completeness is not claimed. See docs/evidence-emission.md.
|
||||||
|
|
||||||
|
This module has no SQL against audit-core's store and no credential
|
||||||
|
for it beyond a sender token used to POST /v1/events. The external
|
||||||
|
copy cannot be rewritten through tenant-engine's runtime database
|
||||||
|
credential because we do not hold that credential.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "audit-core.event.v1alpha1"
|
||||||
|
SOURCE = "tenant-engine"
|
||||||
|
|
||||||
|
|
||||||
|
def new_event_id() -> str:
|
||||||
|
return str(uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def envelope_for(
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
event_type: str,
|
||||||
|
tenant_id: str,
|
||||||
|
observed_at: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"event_id": event_id,
|
||||||
|
"observed_at": observed_at,
|
||||||
|
"tenant": tenant_id,
|
||||||
|
"scope": "tenant-engine",
|
||||||
|
"source": SOURCE,
|
||||||
|
"actor": payload.get("actor") or payload.get("granted_by") or payload.get("changed_by"),
|
||||||
|
"action": event_type,
|
||||||
|
"resource": f"tenant:{tenant_id}",
|
||||||
|
"outcome": "recorded",
|
||||||
|
"reason": payload.get("reason") or payload.get("authorization_reason"),
|
||||||
|
"details": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeliveryResult:
|
||||||
|
event_id: str
|
||||||
|
status: str # delivered | duplicate | retry | dead | skipped
|
||||||
|
http_status: int | None = None
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AuditCoreClient:
|
||||||
|
"""POST /v1/events. The only audit-core surface this engine holds."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
timeout_seconds: float = 3.0,
|
||||||
|
token_file: str | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token_file = token_file
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=httpx.Timeout(timeout_seconds),
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_event(self, envelope: dict[str, Any]) -> DeliveryResult:
|
||||||
|
event_id = str(envelope.get("event_id") or "")
|
||||||
|
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||||
|
if self.token_file:
|
||||||
|
try:
|
||||||
|
token = open(self.token_file, encoding="utf-8").read().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
return DeliveryResult(event_id, "retry", None, f"token_unreadable:{exc}")
|
||||||
|
if not token:
|
||||||
|
return DeliveryResult(event_id, "retry", None, "token_empty")
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
try:
|
||||||
|
response = self._client.post("/v1/events", json=envelope, headers=headers)
|
||||||
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
|
return DeliveryResult(event_id, "retry", None, f"unreachable:{exc.__class__.__name__}")
|
||||||
|
if response.status_code in (200, 202):
|
||||||
|
status = "duplicate" if response.status_code == 200 else "delivered"
|
||||||
|
return DeliveryResult(event_id, status, response.status_code)
|
||||||
|
if response.status_code in (400, 409):
|
||||||
|
return DeliveryResult(event_id, "dead", response.status_code, "rejected")
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
return DeliveryResult(event_id, "retry", response.status_code, "unauthorized")
|
||||||
|
return DeliveryResult(event_id, "retry", response.status_code, "unavailable")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Protocol
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
|
from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id
|
||||||
|
from tenant_engine.stance import FAIL_CLOSED
|
||||||
|
|
||||||
# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's
|
|
||||||
# resource/action vocabulary exactly -- the two repos coordinate on these
|
|
||||||
# strings, neither invents its own.
|
|
||||||
_RESOURCE_TYPES: dict[str, str] = {
|
_RESOURCE_TYPES: dict[str, str] = {
|
||||||
"tenant.read": "tenant",
|
"tenant.read": "tenant",
|
||||||
"tenant.create": "tenant",
|
"tenant.create": "tenant",
|
||||||
|
|
@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = {
|
||||||
"tenant.role.read": "role-grant",
|
"tenant.role.read": "role-grant",
|
||||||
"tenant.role.read.live": "role-grant",
|
"tenant.role.read.live": "role-grant",
|
||||||
"tenant.plan.assign": "plan-assignment",
|
"tenant.plan.assign": "plan-assignment",
|
||||||
# TEN-WP-0005: lifecycle actions are distinct so policy can separate a
|
|
||||||
# metadata edit from a retirement.
|
|
||||||
"tenant.update": "tenant",
|
"tenant.update": "tenant",
|
||||||
"tenant.retire": "tenant",
|
"tenant.retire": "tenant",
|
||||||
"tenant.reactivate": "tenant",
|
"tenant.reactivate": "tenant",
|
||||||
# TEN-WP-0006: reading a ceiling and changing one are separate privileges.
|
|
||||||
# A PDP needs the read; almost nothing needs the write.
|
|
||||||
"tenant.guardrail.read": "guardrail",
|
"tenant.guardrail.read": "guardrail",
|
||||||
"tenant.guardrail.set": "guardrail",
|
"tenant.guardrail.set": "guardrail",
|
||||||
# TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is
|
|
||||||
# separable from a metadata edit rather than folded into tenant.update.
|
|
||||||
"tenant.grouping.set": "tenant",
|
"tenant.grouping.set": "tenant",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthorizationOutcome:
|
||||||
|
"""The record companion §5 requires on every protected action.
|
||||||
|
|
||||||
|
Either a decision (source="decision", decision_id set) or the
|
||||||
|
application of the published fail-closed stance (source="stance").
|
||||||
|
"""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
tenant_id: str
|
||||||
|
actor: str
|
||||||
|
allowed: bool
|
||||||
|
source: str
|
||||||
|
reason: str
|
||||||
|
decision_id: str | None = None
|
||||||
|
request_digest: str | None = None
|
||||||
|
effect: str | None = None
|
||||||
|
stance: str | None = None
|
||||||
|
|
||||||
|
def as_payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"authorization_source": self.source,
|
||||||
|
"authorization_decision_id": self.decision_id,
|
||||||
|
"authorization_request_digest": self.request_digest,
|
||||||
|
"authorization_effect": self.effect,
|
||||||
|
"authorization_stance": self.stance,
|
||||||
|
"authorization_reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WriteAuthorizationDeniedError(Exception):
|
class WriteAuthorizationDeniedError(Exception):
|
||||||
def __init__(self, action: str, reason: str = "denied") -> None:
|
def __init__(self, outcome: AuthorizationOutcome) -> None:
|
||||||
super().__init__(f"{action}: {reason}")
|
super().__init__(f"{outcome.action}: {outcome.reason}")
|
||||||
self.action = action
|
self.action = outcome.action
|
||||||
self.reason = reason
|
self.reason = outcome.reason
|
||||||
|
self.outcome = outcome
|
||||||
|
|
||||||
|
|
||||||
class WriteAuthorizer(Protocol):
|
class WriteAuthorizer(Protocol):
|
||||||
"""The single seam every write endpoint calls before mutating anything.
|
"""The single seam every write (and authorized read) calls before the store."""
|
||||||
|
|
||||||
Per the boundary contract, tenant-engine never self-authorizes writes --
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
flex-auth is meant to gate them. A real flex-auth integration is an
|
"""Return an allow record, or raise WriteAuthorizationDeniedError."""
|
||||||
explicit non-goal of TEN-WP-0002; this Protocol exists so swapping one in
|
|
||||||
later touches this one seam, not every endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
"""Raise WriteAuthorizationDeniedError if the write is not authorized."""
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class DefaultDenyWriteAuthorizer:
|
class DefaultDenyWriteAuthorizer:
|
||||||
"""Deny every write. The correct default until a real authorizer exists."""
|
"""Apply the published fail-closed stance when access-engine is unset."""
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
raise WriteAuthorizationDeniedError(
|
outcome = AuthorizationOutcome(
|
||||||
action, "no flex-auth integration configured (default-deny stub)"
|
action=action,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor=actor,
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason="no flex-auth integration configured (default-deny stub)",
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
)
|
)
|
||||||
|
raise WriteAuthorizationDeniedError(outcome)
|
||||||
|
|
||||||
|
|
||||||
class FlexAuthWriteAuthorizer:
|
class FlexAuthWriteAuthorizer:
|
||||||
"""Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008).
|
"""Gates calls through flex-auth's POST /v1/check.
|
||||||
|
|
||||||
Until FLEX-WP-0008's policy package exists for tenant-engine, every
|
Does not cache verdicts. A previous allow cannot authorize a later
|
||||||
check resolves to deny -- that's the correct fail-closed behavior, not
|
request — every call is a new check.
|
||||||
a bug in this client (see flex_auth.FlexAuthCheckClient).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, client: FlexAuthCheckClient) -> None:
|
def __init__(self, *, client: FlexAuthCheckClient) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
resource_type = _RESOURCE_TYPES.get(action, "tenant")
|
resource_type = _RESOURCE_TYPES.get(action, "tenant")
|
||||||
request = CheckRequest(
|
request = CheckRequest(
|
||||||
request_id=new_request_id(),
|
request_id=new_request_id(),
|
||||||
|
|
@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer:
|
||||||
resource_id=tenant_id,
|
resource_id=tenant_id,
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
)
|
)
|
||||||
if not self._client.is_allowed(request):
|
result = self._client.check(request)
|
||||||
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check")
|
outcome = AuthorizationOutcome(
|
||||||
|
action=action,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor=actor,
|
||||||
|
allowed=result.allowed,
|
||||||
|
source=result.source,
|
||||||
|
reason=result.reason,
|
||||||
|
decision_id=result.decision_id,
|
||||||
|
request_digest=result.request_digest,
|
||||||
|
effect=result.effect,
|
||||||
|
stance=result.stance,
|
||||||
|
)
|
||||||
|
if not result.allowed:
|
||||||
|
raise WriteAuthorizationDeniedError(outcome)
|
||||||
|
return outcome
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,25 @@ class Settings:
|
||||||
database_path: str | None = None
|
database_path: str | None = None
|
||||||
database_url_file: str | None = None
|
database_url_file: str | None = None
|
||||||
flex_auth_token_file: str | None = None
|
flex_auth_token_file: str | None = None
|
||||||
|
audit_core_base_url: str | None = None
|
||||||
|
audit_core_token_file: str | None = None
|
||||||
|
audit_core_timeout_seconds: float = 3.0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
return cls(
|
return cls(
|
||||||
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None,
|
flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None,
|
||||||
flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
flex_auth_timeout_seconds=float(
|
||||||
|
os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")
|
||||||
|
),
|
||||||
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
|
host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"),
|
||||||
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
|
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
|
||||||
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
|
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None,
|
||||||
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
|
database_url_file=os.getenv("TENANT_ENGINE_DATABASE_URL_FILE") or None,
|
||||||
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
|
flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None,
|
||||||
|
audit_core_base_url=os.getenv("TENANT_ENGINE_AUDIT_CORE_URL") or None,
|
||||||
|
audit_core_token_file=os.getenv("TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE") or None,
|
||||||
|
audit_core_timeout_seconds=float(
|
||||||
|
os.getenv("TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS", "3")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,7 @@ class Tenant:
|
||||||
# resolve guardrails through the reserved profile. Giving one a
|
# resolve guardrails through the reserved profile. Giving one a
|
||||||
# grouping would silently move the platform's own identity onto
|
# grouping would silently move the platform's own identity onto
|
||||||
# the grouping ladder.
|
# the grouping ladder.
|
||||||
raise ImmutableFieldError(
|
raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified")
|
||||||
"reserved tenants are ungrouped and cannot be reclassified"
|
|
||||||
)
|
|
||||||
if self.lifecycle is not TenantLifecycle.ACTIVE:
|
if self.lifecycle is not TenantLifecycle.ACTIVE:
|
||||||
raise InvalidLifecycleTransitionError(
|
raise InvalidLifecycleTransitionError(
|
||||||
"grouping of a retired tenant cannot be changed; reactivate first"
|
"grouping of a retired tenant cannot be changed; reactivate first"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json)
|
from tenant_engine.stance import FAIL_CLOSED
|
||||||
# allows five effects; only "allow" authorizes anything.
|
|
||||||
ALLOW_EFFECT = "allow"
|
ALLOW_EFFECT = "allow"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,15 +48,35 @@ class CheckRequest:
|
||||||
"context": self.context,
|
"context": self.context,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def digest(self) -> str:
|
||||||
|
canonical = json.dumps(self.to_json(), sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CheckResult:
|
||||||
|
"""What the authorizer keeps after POST /v1/check.
|
||||||
|
|
||||||
|
`is_allowed` used to drop the envelope. Companion §5 / statute §6.4
|
||||||
|
require the decision record (or a recorded stance) to survive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
source: str
|
||||||
|
reason: str
|
||||||
|
request_digest: str
|
||||||
|
decision_id: str | None = None
|
||||||
|
effect: str | None = None
|
||||||
|
stance: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FlexAuthCheckClient:
|
class FlexAuthCheckClient:
|
||||||
"""Client for flex-auth's POST /v1/check.
|
"""Client for flex-auth's POST /v1/check.
|
||||||
|
|
||||||
Fail-closed by construction: every non-"allow" effect, every non-2xx
|
Fail-closed by construction. Every non-allow effect, every non-2xx,
|
||||||
response, every malformed body, and every transport failure (timeout,
|
every malformed body, and every transport failure resolves to a
|
||||||
connection error) resolves to `False` from `is_allowed()`. Nothing
|
CheckResult with allowed=False. Nothing raises past this boundary.
|
||||||
raises past this boundary -- callers (the WriteAuthorizer seam) get a
|
Verdicts are never cached: every call is a new POST.
|
||||||
plain deny, not an exception to handle inconsistently.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -73,33 +96,85 @@ class FlexAuthCheckClient:
|
||||||
transport=transport,
|
transport=transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_allowed(self, request: CheckRequest) -> bool:
|
def check(self, request: CheckRequest) -> CheckResult:
|
||||||
|
digest = request.digest()
|
||||||
try:
|
try:
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
if self.bearer_token_file:
|
if self.bearer_token_file:
|
||||||
# Projected ServiceAccount tokens rotate. Read on each check
|
|
||||||
# instead of pinning the token for the lifetime of the process.
|
|
||||||
with open(self.bearer_token_file, encoding="utf-8") as token_file:
|
with open(self.bearer_token_file, encoding="utf-8") as token_file:
|
||||||
token = token_file.read().strip()
|
token = token_file.read().strip()
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason="caller_token_empty",
|
||||||
|
request_digest=digest,
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
|
)
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
|
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
|
||||||
except (httpx.HTTPError, OSError):
|
except (httpx.HTTPError, OSError):
|
||||||
return False
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason="access_engine_unreachable",
|
||||||
|
request_digest=digest,
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
return False
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason=f"access_engine_http_{response.status_code}",
|
||||||
|
request_digest=digest,
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
envelope = response.json()
|
envelope = response.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason="access_engine_malformed_body",
|
||||||
|
request_digest=digest,
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
return False
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="stance",
|
||||||
|
reason="access_engine_malformed_body",
|
||||||
|
request_digest=digest,
|
||||||
|
stance=FAIL_CLOSED,
|
||||||
|
)
|
||||||
|
|
||||||
return envelope.get("effect") == ALLOW_EFFECT
|
effect = envelope.get("effect")
|
||||||
|
decision_id = envelope.get("id")
|
||||||
|
if not isinstance(decision_id, str):
|
||||||
|
decision_id = None
|
||||||
|
if effect == ALLOW_EFFECT:
|
||||||
|
return CheckResult(
|
||||||
|
allowed=True,
|
||||||
|
source="decision",
|
||||||
|
reason="allow",
|
||||||
|
request_digest=digest,
|
||||||
|
decision_id=decision_id,
|
||||||
|
effect=ALLOW_EFFECT,
|
||||||
|
)
|
||||||
|
return CheckResult(
|
||||||
|
allowed=False,
|
||||||
|
source="decision",
|
||||||
|
reason="denied_by_flex_auth_policy_check",
|
||||||
|
request_digest=digest,
|
||||||
|
decision_id=decision_id,
|
||||||
|
effect=str(effect) if effect is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_allowed(self, request: CheckRequest) -> bool:
|
||||||
|
return self.check(request).allowed
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard
|
||||||
ConnectionPool = None
|
ConnectionPool = None
|
||||||
PoolTimeout = None
|
PoolTimeout = None
|
||||||
|
|
||||||
|
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||||
from tenant_engine.domain import (
|
from tenant_engine.domain import (
|
||||||
CapabilityRole,
|
CapabilityRole,
|
||||||
PlanAssignment,
|
PlanAssignment,
|
||||||
|
|
@ -35,9 +36,11 @@ from tenant_engine.guardrail import (
|
||||||
load_limit,
|
load_limit,
|
||||||
)
|
)
|
||||||
from tenant_engine.store import (
|
from tenant_engine.store import (
|
||||||
|
AuthorizationRecord,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
GrantNotFoundError,
|
GrantNotFoundError,
|
||||||
IdempotencyConflictError,
|
IdempotencyConflictError,
|
||||||
|
OutboxRow,
|
||||||
StoreUnavailableError,
|
StoreUnavailableError,
|
||||||
TenantAlreadyExistsError,
|
TenantAlreadyExistsError,
|
||||||
TenantNotFoundError,
|
TenantNotFoundError,
|
||||||
|
|
@ -118,7 +121,7 @@ class PostgresTenantStore:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
conn.execute("SELECT 1").fetchone()
|
conn.execute("SELECT 1").fetchone()
|
||||||
|
|
||||||
def create_tenant(self, tenant: Tenant) -> None:
|
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
with self._pool.connection() as conn, conn.transaction():
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -144,7 +147,7 @@ class PostgresTenantStore:
|
||||||
conn,
|
conn,
|
||||||
"tenant_created",
|
"tenant_created",
|
||||||
tenant.tenant_id,
|
tenant.tenant_id,
|
||||||
{"identifier": tenant.identifier, "grouping": tenant.grouping},
|
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
|
||||||
)
|
)
|
||||||
except StoreUnavailableError as exc:
|
except StoreUnavailableError as exc:
|
||||||
if isinstance(exc.__cause__, UniqueViolation):
|
if isinstance(exc.__cause__, UniqueViolation):
|
||||||
|
|
@ -204,15 +207,13 @@ class PostgresTenantStore:
|
||||||
updated.tenant_id,
|
updated.tenant_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._record_receipt(
|
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
|
||||||
conn, updated, idempotency_key, request_fingerprint
|
|
||||||
)
|
|
||||||
self._emit(
|
self._emit(
|
||||||
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
|
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
|
||||||
)
|
)
|
||||||
return updated, False
|
return updated, False
|
||||||
|
|
||||||
def grant_role(self, grant: RoleGrant) -> None:
|
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
tenant = self.get_tenant(grant.tenant_id)
|
tenant = self.get_tenant(grant.tenant_id)
|
||||||
self._require_active(tenant, "grant a role")
|
self._require_active(tenant, "grant a role")
|
||||||
with self._pool.connection() as conn, conn.transaction():
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
|
|
@ -248,10 +249,13 @@ class PostgresTenantStore:
|
||||||
"role": grant.role.value,
|
"role": grant.role.value,
|
||||||
"grant_reason": grant.grant_reason,
|
"grant_reason": grant.grant_reason,
|
||||||
"correlation_id": grant.correlation_id,
|
"correlation_id": grant.correlation_id,
|
||||||
|
**(authz or {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
def revoke_role(
|
||||||
|
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
|
||||||
|
) -> RoleGrant:
|
||||||
tenant = self.get_tenant(tenant_id)
|
tenant = self.get_tenant(tenant_id)
|
||||||
with self._pool.connection() as conn, conn.transaction():
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|
@ -266,7 +270,7 @@ class PostgresTenantStore:
|
||||||
conn,
|
conn,
|
||||||
"role_revoked",
|
"role_revoked",
|
||||||
tenant.tenant_id,
|
tenant.tenant_id,
|
||||||
{"grant_id": grant_id, "role": grant.role.value},
|
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
|
||||||
)
|
)
|
||||||
return grant
|
return grant
|
||||||
|
|
||||||
|
|
@ -279,7 +283,9 @@ class PostgresTenantStore:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
||||||
|
|
||||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
def assign_plan(
|
||||||
|
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
tenant = self.get_tenant(assignment.tenant_id)
|
tenant = self.get_tenant(assignment.tenant_id)
|
||||||
self._require_active(tenant, "assign a plan")
|
self._require_active(tenant, "assign a plan")
|
||||||
with self._pool.connection() as conn, conn.transaction():
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
|
|
@ -290,17 +296,89 @@ class PostgresTenantStore:
|
||||||
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
|
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
|
||||||
)
|
)
|
||||||
self._emit(
|
self._emit(
|
||||||
conn, "plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id}
|
conn,
|
||||||
|
"plan_assigned",
|
||||||
|
tenant.tenant_id,
|
||||||
|
{"plan_id": assignment.plan_id, **(authz or {})},
|
||||||
)
|
)
|
||||||
|
|
||||||
def events(self) -> list[DomainEvent]:
|
def events_for(self, tenant_id: str) -> list[DomainEvent]:
|
||||||
|
tenant = self.get_tenant(tenant_id)
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
rows = conn.execute("SELECT * FROM events ORDER BY seq").fetchall()
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM events WHERE tenant_id = %s ORDER BY seq",
|
||||||
|
(tenant.tenant_id,),
|
||||||
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
|
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def record_authorization(self, record: AuthorizationRecord) -> None:
|
||||||
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO authz_records
|
||||||
|
(action, tenant_id, actor, allowed, source, reason, at,
|
||||||
|
decision_id, request_digest, effect, stance)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
record.action,
|
||||||
|
record.tenant_id,
|
||||||
|
record.actor,
|
||||||
|
record.allowed,
|
||||||
|
record.source,
|
||||||
|
record.reason,
|
||||||
|
record.at,
|
||||||
|
record.decision_id,
|
||||||
|
record.request_digest,
|
||||||
|
record.effect,
|
||||||
|
record.stance,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
|
||||||
|
keys = [tenant_id]
|
||||||
|
try:
|
||||||
|
keys.append(self.get_tenant(tenant_id).tenant_id)
|
||||||
|
except TenantNotFoundError:
|
||||||
|
pass
|
||||||
|
placeholders = ",".join(["%s"] * len(keys))
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
|
||||||
|
keys,
|
||||||
|
).fetchall()
|
||||||
|
return [_authz_row(row) for row in rows]
|
||||||
|
|
||||||
|
def pending_outbox(self) -> list[OutboxRow]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT * FROM audit_outbox
|
||||||
|
WHERE delivered_at IS NULL AND dead_at IS NULL
|
||||||
|
ORDER BY created_at"""
|
||||||
|
).fetchall()
|
||||||
|
return [_outbox_row(row) for row in rows]
|
||||||
|
|
||||||
|
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
|
||||||
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
|
if status in {"delivered", "duplicate"}:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE audit_outbox SET delivered_at = %s, last_error = NULL
|
||||||
|
WHERE event_id = %s""",
|
||||||
|
(datetime.now(UTC), event_id),
|
||||||
|
)
|
||||||
|
elif status == "dead":
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE audit_outbox SET dead_at = %s, last_error = %s WHERE event_id = %s",
|
||||||
|
(datetime.now(UTC), detail, event_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE audit_outbox SET attempts = attempts + 1, last_error = %s
|
||||||
|
WHERE event_id = %s""",
|
||||||
|
(detail, event_id),
|
||||||
|
)
|
||||||
|
|
||||||
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
|
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
|
||||||
tenant = self.get_tenant(tenant_id)
|
tenant = self.get_tenant(tenant_id)
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
|
|
@ -333,6 +411,7 @@ class PostgresTenantStore:
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
request_fingerprint: str,
|
request_fingerprint: str,
|
||||||
at: datetime,
|
at: datetime,
|
||||||
|
authz: dict[str, Any] | None = None,
|
||||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||||
DEFAULT_REGISTRY.get(limit_key)
|
DEFAULT_REGISTRY.get(limit_key)
|
||||||
with self._pool.connection() as conn, conn.transaction():
|
with self._pool.connection() as conn, conn.transaction():
|
||||||
|
|
@ -428,6 +507,7 @@ class PostgresTenantStore:
|
||||||
"cleared": value is None,
|
"cleared": value is None,
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
"version": updated.version,
|
"version": updated.version,
|
||||||
|
**(authz or {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return updated, change, False
|
return updated, change, False
|
||||||
|
|
@ -467,9 +547,24 @@ class PostgresTenantStore:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
def _emit(conn: Any, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
event_id = str(payload.get("event_id") or new_event_id())
|
||||||
|
payload = {**payload, "event_id": event_id}
|
||||||
|
at = datetime.now(UTC)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
|
"INSERT INTO events (event_type, tenant_id, at, payload) VALUES (%s, %s, %s, %s)",
|
||||||
(event_type, tenant_id, datetime.now(UTC), Jsonb(payload)),
|
(event_type, tenant_id, at, Jsonb(payload)),
|
||||||
|
)
|
||||||
|
envelope = envelope_for(
|
||||||
|
event_id=event_id,
|
||||||
|
event_type=event_type,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
observed_at=at.isoformat(),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s)""",
|
||||||
|
(event_id, tenant_id, Jsonb(envelope), at),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,9 +596,7 @@ def _row(tenant: Tenant) -> dict[str, Any]:
|
||||||
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
||||||
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
|
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
|
||||||
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
|
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
|
||||||
"reactivated_at": (
|
"reactivated_at": (tenant.reactivated_at.isoformat() if tenant.reactivated_at else None),
|
||||||
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -539,3 +632,32 @@ def _change(row: Mapping[str, Any]) -> GuardrailChange:
|
||||||
correlation_id=row["correlation_id"],
|
correlation_id=row["correlation_id"],
|
||||||
changed_at=_dt(row["changed_at"]),
|
changed_at=_dt(row["changed_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authz_row(row: Mapping[str, Any]) -> AuthorizationRecord:
|
||||||
|
return AuthorizationRecord(
|
||||||
|
action=row["action"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
actor=row["actor"],
|
||||||
|
allowed=bool(row["allowed"]),
|
||||||
|
source=row["source"],
|
||||||
|
reason=row["reason"],
|
||||||
|
at=_dt(row["at"]),
|
||||||
|
decision_id=row["decision_id"],
|
||||||
|
request_digest=row["request_digest"],
|
||||||
|
effect=row["effect"],
|
||||||
|
stance=row["stance"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _outbox_row(row: Mapping[str, Any]) -> OutboxRow:
|
||||||
|
return OutboxRow(
|
||||||
|
event_id=row["event_id"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
envelope=row["envelope"],
|
||||||
|
created_at=_dt(row["created_at"]),
|
||||||
|
attempts=row["attempts"],
|
||||||
|
last_error=row["last_error"],
|
||||||
|
delivered_at=_dt(row["delivered_at"]) if row["delivered_at"] else None,
|
||||||
|
dead_at=_dt(row["dead_at"]) if row["dead_at"] else None,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from datetime import datetime
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||||
from tenant_engine.domain import (
|
from tenant_engine.domain import (
|
||||||
CapabilityRole,
|
CapabilityRole,
|
||||||
PlanAssignment,
|
PlanAssignment,
|
||||||
|
|
@ -24,9 +25,11 @@ from tenant_engine.guardrail import (
|
||||||
load_limit,
|
load_limit,
|
||||||
)
|
)
|
||||||
from tenant_engine.store import (
|
from tenant_engine.store import (
|
||||||
|
AuthorizationRecord,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
GrantNotFoundError,
|
GrantNotFoundError,
|
||||||
IdempotencyConflictError,
|
IdempotencyConflictError,
|
||||||
|
OutboxRow,
|
||||||
TenantAlreadyExistsError,
|
TenantAlreadyExistsError,
|
||||||
TenantNotFoundError,
|
TenantNotFoundError,
|
||||||
VersionConflictError,
|
VersionConflictError,
|
||||||
|
|
@ -62,6 +65,19 @@ class SQLiteTenantStore:
|
||||||
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
|
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL,
|
||||||
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
|
tenant_id TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS authz_records (
|
||||||
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL, tenant_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||||
|
allowed INTEGER NOT NULL, source TEXT NOT NULL, reason TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL, decision_id TEXT, request_digest TEXT,
|
||||||
|
effect TEXT, stance TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_outbox (
|
||||||
|
event_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
||||||
|
envelope TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT,
|
||||||
|
delivered_at TEXT, dead_at TEXT
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS idempotency_receipts (
|
CREATE TABLE IF NOT EXISTS idempotency_receipts (
|
||||||
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||||
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
||||||
|
|
@ -110,7 +126,7 @@ class SQLiteTenantStore:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._db.execute("SELECT 1").fetchone()
|
self._db.execute("SELECT 1").fetchone()
|
||||||
|
|
||||||
def create_tenant(self, tenant: Tenant) -> None:
|
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
with self._lock, self._db:
|
with self._lock, self._db:
|
||||||
try:
|
try:
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
|
|
@ -118,16 +134,31 @@ class SQLiteTenantStore:
|
||||||
contact_email, lifecycle, version, created_at, updated_at,
|
contact_email, lifecycle, version, created_at, updated_at,
|
||||||
retired_at, reactivated_at)
|
retired_at, reactivated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(tenant.tenant_id, tenant.identifier, tenant.grouping, tenant.display_name,
|
(
|
||||||
tenant.contact_email, tenant.lifecycle.value, tenant.version,
|
tenant.tenant_id,
|
||||||
_iso(tenant.created_at), _iso(tenant.updated_at),
|
tenant.identifier,
|
||||||
_iso(tenant.retired_at), _iso(tenant.reactivated_at)),
|
tenant.grouping,
|
||||||
|
tenant.display_name,
|
||||||
|
tenant.contact_email,
|
||||||
|
tenant.lifecycle.value,
|
||||||
|
tenant.version,
|
||||||
|
_iso(tenant.created_at),
|
||||||
|
_iso(tenant.updated_at),
|
||||||
|
_iso(tenant.retired_at),
|
||||||
|
_iso(tenant.reactivated_at),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise TenantAlreadyExistsError(tenant.identifier) from exc
|
raise TenantAlreadyExistsError(tenant.identifier) from exc
|
||||||
self._emit("tenant_created", tenant.tenant_id, {
|
self._emit(
|
||||||
"identifier": tenant.identifier, "grouping": tenant.grouping,
|
"tenant_created",
|
||||||
})
|
tenant.tenant_id,
|
||||||
|
{
|
||||||
|
"identifier": tenant.identifier,
|
||||||
|
"grouping": tenant.grouping,
|
||||||
|
**(authz or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||||
# Reads take the same lock as writes. One sqlite3 connection is shared
|
# Reads take the same lock as writes. One sqlite3 connection is shared
|
||||||
|
|
@ -189,15 +220,27 @@ class SQLiteTenantStore:
|
||||||
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
|
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
|
||||||
retired_at = ?, reactivated_at = ?
|
retired_at = ?, reactivated_at = ?
|
||||||
WHERE tenant_id = ?""",
|
WHERE tenant_id = ?""",
|
||||||
(updated.grouping, updated.display_name, updated.contact_email,
|
(
|
||||||
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
|
updated.grouping,
|
||||||
_iso(updated.retired_at), _iso(updated.reactivated_at),
|
updated.display_name,
|
||||||
updated.tenant_id),
|
updated.contact_email,
|
||||||
|
updated.lifecycle.value,
|
||||||
|
updated.version,
|
||||||
|
_iso(updated.updated_at),
|
||||||
|
_iso(updated.retired_at),
|
||||||
|
_iso(updated.reactivated_at),
|
||||||
|
updated.tenant_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
||||||
(updated.tenant_id, idempotency_key, request_fingerprint,
|
(
|
||||||
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
|
updated.tenant_id,
|
||||||
|
idempotency_key,
|
||||||
|
request_fingerprint,
|
||||||
|
json.dumps(_row(updated)),
|
||||||
|
datetime.now().astimezone().isoformat(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
|
self._emit(event_type, updated.tenant_id, {**evidence, "version": updated.version})
|
||||||
except BaseException:
|
except BaseException:
|
||||||
|
|
@ -251,6 +294,7 @@ class SQLiteTenantStore:
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
request_fingerprint: str,
|
request_fingerprint: str,
|
||||||
at: datetime,
|
at: datetime,
|
||||||
|
authz: dict[str, Any] | None = None,
|
||||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||||
tenant = self.get_tenant(tenant_id)
|
tenant = self.get_tenant(tenant_id)
|
||||||
DEFAULT_REGISTRY.get(limit_key)
|
DEFAULT_REGISTRY.get(limit_key)
|
||||||
|
|
@ -274,8 +318,11 @@ class SQLiteTenantStore:
|
||||||
replayed = _tenant(json.loads(receipt["result"]))
|
replayed = _tenant(json.loads(receipt["result"]))
|
||||||
self._db.rollback()
|
self._db.rollback()
|
||||||
prior = next(
|
prior = next(
|
||||||
(c for c in self.guardrail_changes(tenant.tenant_id)
|
(
|
||||||
if c.change_id == change_id),
|
c
|
||||||
|
for c in self.guardrail_changes(tenant.tenant_id)
|
||||||
|
if c.change_id == change_id
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
return replayed, prior, True
|
return replayed, prior, True
|
||||||
|
|
@ -317,16 +364,29 @@ class SQLiteTenantStore:
|
||||||
dumped = dump_limit(value)
|
dumped = dump_limit(value)
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(current.tenant_id, limit_key, dumped["kind"], dumped["amount"],
|
(
|
||||||
dumped["currency"], dumped["period"]),
|
current.tenant_id,
|
||||||
|
limit_key,
|
||||||
|
dumped["kind"],
|
||||||
|
dumped["amount"],
|
||||||
|
dumped["currency"],
|
||||||
|
dumped["period"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(change.change_id, change.tenant_id, change.limit_key,
|
(
|
||||||
json.dumps(dump_limit(change.previous)) if change.previous else None,
|
change.change_id,
|
||||||
json.dumps(dump_limit(change.current)) if change.current else None,
|
change.tenant_id,
|
||||||
change.changed_by, change.reason, change.correlation_id, _iso(at)),
|
change.limit_key,
|
||||||
|
json.dumps(dump_limit(change.previous)) if change.previous else None,
|
||||||
|
json.dumps(dump_limit(change.current)) if change.current else None,
|
||||||
|
change.changed_by,
|
||||||
|
change.reason,
|
||||||
|
change.correlation_id,
|
||||||
|
_iso(at),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
updated = replace(current, version=current.version + 1, updated_at=at)
|
updated = replace(current, version=current.version + 1, updated_at=at)
|
||||||
|
|
@ -336,35 +396,65 @@ class SQLiteTenantStore:
|
||||||
)
|
)
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
|
||||||
(updated.tenant_id, idempotency_key, request_fingerprint,
|
(
|
||||||
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
|
updated.tenant_id,
|
||||||
|
idempotency_key,
|
||||||
|
request_fingerprint,
|
||||||
|
json.dumps(_row(updated)),
|
||||||
|
datetime.now().astimezone().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._emit(
|
||||||
|
"guardrail_changed",
|
||||||
|
updated.tenant_id,
|
||||||
|
{
|
||||||
|
"limit_key": limit_key,
|
||||||
|
"change_id": change_id,
|
||||||
|
"cleared": value is None,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"version": updated.version,
|
||||||
|
**(authz or {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self._emit("guardrail_changed", updated.tenant_id, {
|
|
||||||
"limit_key": limit_key, "change_id": change_id,
|
|
||||||
"cleared": value is None, "correlation_id": correlation_id,
|
|
||||||
"version": updated.version,
|
|
||||||
})
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self._db.rollback()
|
self._db.rollback()
|
||||||
raise
|
raise
|
||||||
self._db.commit()
|
self._db.commit()
|
||||||
return updated, change, False
|
return updated, change, False
|
||||||
|
|
||||||
def grant_role(self, grant: RoleGrant) -> None:
|
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
tenant = self.get_tenant(grant.tenant_id)
|
tenant = self.get_tenant(grant.tenant_id)
|
||||||
self._require_active(tenant, "grant a role")
|
self._require_active(tenant, "grant a role")
|
||||||
with self._lock, self._db:
|
with self._lock, self._db:
|
||||||
self._db.execute(
|
self._db.execute(
|
||||||
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(grant.grant_id, tenant.tenant_id, grant.role.value, grant.grant_reason,
|
(
|
||||||
grant.plan_id, grant.granted_by, grant.granted_at.isoformat(),
|
grant.grant_id,
|
||||||
grant.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None),
|
tenant.tenant_id,
|
||||||
|
grant.role.value,
|
||||||
|
grant.grant_reason,
|
||||||
|
grant.plan_id,
|
||||||
|
grant.granted_by,
|
||||||
|
grant.granted_at.isoformat(),
|
||||||
|
grant.correlation_id,
|
||||||
|
grant.revoked_at.isoformat() if grant.revoked_at else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._emit(
|
||||||
|
"role_granted",
|
||||||
|
tenant.tenant_id,
|
||||||
|
{
|
||||||
|
"grant_id": grant.grant_id,
|
||||||
|
"role": grant.role.value,
|
||||||
|
"grant_reason": grant.grant_reason,
|
||||||
|
"correlation_id": grant.correlation_id,
|
||||||
|
**(authz or {}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self._emit("role_granted", tenant.tenant_id, {"grant_id": grant.grant_id,
|
|
||||||
"role": grant.role.value, "grant_reason": grant.grant_reason,
|
|
||||||
"correlation_id": grant.correlation_id})
|
|
||||||
|
|
||||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
def revoke_role(
|
||||||
|
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
|
||||||
|
) -> RoleGrant:
|
||||||
tenant = self.get_tenant(tenant_id)
|
tenant = self.get_tenant(tenant_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._db.execute(
|
row = self._db.execute(
|
||||||
|
|
@ -375,10 +465,14 @@ class SQLiteTenantStore:
|
||||||
raise GrantNotFoundError(grant_id)
|
raise GrantNotFoundError(grant_id)
|
||||||
grant = self._grant(row).revoke(at=at)
|
grant = self._grant(row).revoke(at=at)
|
||||||
with self._lock, self._db:
|
with self._lock, self._db:
|
||||||
self._db.execute("UPDATE grants SET revoked_at = ? WHERE grant_id = ?",
|
self._db.execute(
|
||||||
(at.isoformat(), grant_id))
|
"UPDATE grants SET revoked_at = ? WHERE grant_id = ?", (at.isoformat(), grant_id)
|
||||||
self._emit("role_revoked", tenant.tenant_id,
|
)
|
||||||
{"grant_id": grant_id, "role": grant.role.value})
|
self._emit(
|
||||||
|
"role_revoked",
|
||||||
|
tenant.tenant_id,
|
||||||
|
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
|
||||||
|
)
|
||||||
return grant
|
return grant
|
||||||
|
|
||||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||||
|
|
@ -390,7 +484,9 @@ class SQLiteTenantStore:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
return frozenset(CapabilityRole(row["role"]) for row in rows)
|
||||||
|
|
||||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
def assign_plan(
|
||||||
|
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
tenant = self.get_tenant(assignment.tenant_id)
|
tenant = self.get_tenant(assignment.tenant_id)
|
||||||
self._require_active(tenant, "assign a plan")
|
self._require_active(tenant, "assign a plan")
|
||||||
with self._lock, self._db:
|
with self._lock, self._db:
|
||||||
|
|
@ -398,14 +494,94 @@ class SQLiteTenantStore:
|
||||||
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
|
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
|
||||||
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()),
|
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at.isoformat()),
|
||||||
)
|
)
|
||||||
self._emit("plan_assigned", tenant.tenant_id, {"plan_id": assignment.plan_id})
|
self._emit(
|
||||||
|
"plan_assigned",
|
||||||
|
tenant.tenant_id,
|
||||||
|
{"plan_id": assignment.plan_id, **(authz or {})},
|
||||||
|
)
|
||||||
|
|
||||||
def events(self) -> list[DomainEvent]:
|
def events_for(self, tenant_id: str) -> list[DomainEvent]:
|
||||||
|
tenant = self.get_tenant(tenant_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall()
|
rows = self._db.execute(
|
||||||
return [DomainEvent(row["event_type"], row["tenant_id"],
|
"SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,)
|
||||||
datetime.fromisoformat(row["at"]), json.loads(row["payload"]))
|
).fetchall()
|
||||||
for row in rows]
|
return [
|
||||||
|
DomainEvent(
|
||||||
|
row["event_type"],
|
||||||
|
row["tenant_id"],
|
||||||
|
datetime.fromisoformat(row["at"]),
|
||||||
|
json.loads(row["payload"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def record_authorization(self, record: AuthorizationRecord) -> None:
|
||||||
|
with self._lock, self._db:
|
||||||
|
self._db.execute(
|
||||||
|
"""INSERT INTO authz_records
|
||||||
|
(action, tenant_id, actor, allowed, source, reason, at,
|
||||||
|
decision_id, request_digest, effect, stance)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
record.action,
|
||||||
|
record.tenant_id,
|
||||||
|
record.actor,
|
||||||
|
1 if record.allowed else 0,
|
||||||
|
record.source,
|
||||||
|
record.reason,
|
||||||
|
record.at.isoformat(),
|
||||||
|
record.decision_id,
|
||||||
|
record.request_digest,
|
||||||
|
record.effect,
|
||||||
|
record.stance,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
|
||||||
|
keys = {tenant_id}
|
||||||
|
try:
|
||||||
|
keys.add(self.get_tenant(tenant_id).tenant_id)
|
||||||
|
except TenantNotFoundError:
|
||||||
|
pass
|
||||||
|
placeholders = ",".join("?" * len(keys))
|
||||||
|
with self._lock:
|
||||||
|
rows = self._db.execute(
|
||||||
|
f"SELECT * FROM authz_records WHERE tenant_id IN ({placeholders}) ORDER BY seq",
|
||||||
|
tuple(keys),
|
||||||
|
).fetchall()
|
||||||
|
return [_authz_row(row) for row in rows]
|
||||||
|
|
||||||
|
def pending_outbox(self) -> list[OutboxRow]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._db.execute(
|
||||||
|
"""SELECT * FROM audit_outbox
|
||||||
|
WHERE delivered_at IS NULL AND dead_at IS NULL
|
||||||
|
ORDER BY created_at"""
|
||||||
|
).fetchall()
|
||||||
|
return [_outbox_row(row) for row in rows]
|
||||||
|
|
||||||
|
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
|
||||||
|
now = datetime.now().astimezone().isoformat()
|
||||||
|
with self._lock, self._db:
|
||||||
|
if status in {"delivered", "duplicate"}:
|
||||||
|
self._db.execute(
|
||||||
|
"""UPDATE audit_outbox
|
||||||
|
SET delivered_at = ?, last_error = NULL WHERE event_id = ?""",
|
||||||
|
(now, event_id),
|
||||||
|
)
|
||||||
|
elif status == "dead":
|
||||||
|
self._db.execute(
|
||||||
|
"UPDATE audit_outbox SET dead_at = ?, last_error = ? WHERE event_id = ?",
|
||||||
|
(now, detail, event_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._db.execute(
|
||||||
|
"""UPDATE audit_outbox
|
||||||
|
SET attempts = attempts + 1, last_error = ?
|
||||||
|
WHERE event_id = ?""",
|
||||||
|
(detail, event_id),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _require_active(tenant: Tenant, what: str) -> None:
|
def _require_active(tenant: Tenant, what: str) -> None:
|
||||||
|
|
@ -413,22 +589,74 @@ class SQLiteTenantStore:
|
||||||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||||
|
|
||||||
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
|
def _emit(self, event_type: str, tenant_id: str, payload: dict) -> None:
|
||||||
|
event_id = str(payload.get("event_id") or new_event_id())
|
||||||
|
payload = {**payload, "event_id": event_id}
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
|
self._db.execute(
|
||||||
(event_type, tenant_id, now.isoformat(), json.dumps(payload)))
|
"INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
|
||||||
|
(event_type, tenant_id, now.isoformat(), json.dumps(payload)),
|
||||||
|
)
|
||||||
|
envelope = envelope_for(
|
||||||
|
event_id=event_id,
|
||||||
|
event_type=event_type,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
observed_at=now.isoformat(),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self._db.execute(
|
||||||
|
"""INSERT INTO audit_outbox (event_id, tenant_id, envelope, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)""",
|
||||||
|
(event_id, tenant_id, json.dumps(envelope), now.isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _grant(row: sqlite3.Row) -> RoleGrant:
|
def _grant(row: sqlite3.Row) -> RoleGrant:
|
||||||
return RoleGrant(row["grant_id"], row["tenant_id"], CapabilityRole(row["role"]),
|
return RoleGrant(
|
||||||
row["grant_reason"], row["plan_id"], row["granted_by"],
|
row["grant_id"],
|
||||||
datetime.fromisoformat(row["granted_at"]), row["correlation_id"],
|
row["tenant_id"],
|
||||||
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None)
|
CapabilityRole(row["role"]),
|
||||||
|
row["grant_reason"],
|
||||||
|
row["plan_id"],
|
||||||
|
row["granted_by"],
|
||||||
|
datetime.fromisoformat(row["granted_at"]),
|
||||||
|
row["correlation_id"],
|
||||||
|
datetime.fromisoformat(row["revoked_at"]) if row["revoked_at"] else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: datetime | None) -> str | None:
|
def _iso(value: datetime | None) -> str | None:
|
||||||
return value.isoformat() if value else None
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _authz_row(row: sqlite3.Row) -> AuthorizationRecord:
|
||||||
|
return AuthorizationRecord(
|
||||||
|
action=row["action"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
actor=row["actor"],
|
||||||
|
allowed=bool(row["allowed"]),
|
||||||
|
source=row["source"],
|
||||||
|
reason=row["reason"],
|
||||||
|
at=datetime.fromisoformat(row["at"]),
|
||||||
|
decision_id=row["decision_id"],
|
||||||
|
request_digest=row["request_digest"],
|
||||||
|
effect=row["effect"],
|
||||||
|
stance=row["stance"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _outbox_row(row: sqlite3.Row) -> OutboxRow:
|
||||||
|
return OutboxRow(
|
||||||
|
event_id=row["event_id"],
|
||||||
|
tenant_id=row["tenant_id"],
|
||||||
|
envelope=json.loads(row["envelope"]),
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
attempts=row["attempts"],
|
||||||
|
last_error=row["last_error"],
|
||||||
|
delivered_at=datetime.fromisoformat(row["delivered_at"]) if row["delivered_at"] else None,
|
||||||
|
dead_at=datetime.fromisoformat(row["dead_at"]) if row["dead_at"] else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dt(value: str | None) -> datetime | None:
|
def _dt(value: str | None) -> datetime | None:
|
||||||
return datetime.fromisoformat(value) if value else None
|
return datetime.fromisoformat(value) if value else None
|
||||||
|
|
||||||
|
|
|
||||||
49
src/tenant_engine/stance.py
Normal file
49
src/tenant_engine/stance.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Published unreachable-engine stance, loaded from pep-stance.yaml.
|
||||||
|
|
||||||
|
The map MUST equal shipped behaviour. DefaultDenyWriteAuthorizer and
|
||||||
|
transport-failure deny both apply `fail_closed`. A published map that may
|
||||||
|
drift from this module is worse than none (§6.4 obligation 3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STANCE_PATH = Path(__file__).resolve().parents[2] / "pep-stance.yaml"
|
||||||
|
|
||||||
|
# Shipped behaviour. pep-stance.yaml must equal this dict. Keep the two
|
||||||
|
# in lockstep — tests/test_layer_conformance.py compares them.
|
||||||
|
SHIPPED_STANCE: dict[str, str] = {
|
||||||
|
"unset": "fail_closed",
|
||||||
|
"unreachable": "fail_closed",
|
||||||
|
"non_allow": "fail_closed",
|
||||||
|
"unknown": "fail_closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
FAIL_CLOSED = "fail_closed"
|
||||||
|
|
||||||
|
|
||||||
|
def shipped_stance() -> dict[str, str]:
|
||||||
|
return dict(SHIPPED_STANCE)
|
||||||
|
|
||||||
|
|
||||||
|
def published_stance(text: str | None = None) -> dict[str, str]:
|
||||||
|
"""Parse the `stance:` map from pep-stance.yaml without a YAML runtime dep."""
|
||||||
|
raw = text if text is not None else STANCE_PATH.read_text(encoding="utf-8")
|
||||||
|
in_map = False
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
for line in raw.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("stance:"):
|
||||||
|
in_map = True
|
||||||
|
continue
|
||||||
|
if in_map:
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if not line.startswith(" ") and not line.startswith("\t"):
|
||||||
|
break
|
||||||
|
if ":" not in stripped:
|
||||||
|
continue
|
||||||
|
key, value = stripped.split(":", 1)
|
||||||
|
parsed[key.strip()] = value.split("#", 1)[0].strip()
|
||||||
|
return parsed
|
||||||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||||
from tenant_engine.domain import (
|
from tenant_engine.domain import (
|
||||||
CapabilityRole,
|
CapabilityRole,
|
||||||
PlanAssignment,
|
PlanAssignment,
|
||||||
|
|
@ -64,6 +65,37 @@ class DomainEvent:
|
||||||
payload: dict[str, Any]
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthorizationRecord:
|
||||||
|
"""PEP reconstructability: every authorize attempt, including denies."""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
tenant_id: str
|
||||||
|
actor: str
|
||||||
|
allowed: bool
|
||||||
|
source: str
|
||||||
|
reason: str
|
||||||
|
at: datetime
|
||||||
|
decision_id: str | None = None
|
||||||
|
request_digest: str | None = None
|
||||||
|
effect: str | None = None
|
||||||
|
stance: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OutboxRow:
|
||||||
|
"""Local audit-core outbox. Drain is after commit and non-blocking."""
|
||||||
|
|
||||||
|
event_id: str
|
||||||
|
tenant_id: str
|
||||||
|
envelope: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
attempts: int = 0
|
||||||
|
last_error: str | None = None
|
||||||
|
delivered_at: datetime | None = None
|
||||||
|
dead_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TenantStore(Protocol):
|
class TenantStore(Protocol):
|
||||||
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend.
|
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend.
|
||||||
|
|
||||||
|
|
@ -77,19 +109,31 @@ class TenantStore(Protocol):
|
||||||
tenant_roles claim, KEY-WP-0005-T02).
|
tenant_roles claim, KEY-WP-0005-T02).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def create_tenant(self, tenant: Tenant) -> None: ...
|
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None: ...
|
||||||
|
|
||||||
def get_tenant(self, tenant_id: str) -> Tenant: ...
|
def get_tenant(self, tenant_id: str) -> Tenant: ...
|
||||||
|
|
||||||
def grant_role(self, grant: RoleGrant) -> None: ...
|
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None: ...
|
||||||
|
|
||||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant: ...
|
def revoke_role(
|
||||||
|
self, *, tenant_id: str, grant_id: str, at: datetime, authz: dict[str, Any] | None = None
|
||||||
|
) -> RoleGrant: ...
|
||||||
|
|
||||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ...
|
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]: ...
|
||||||
|
|
||||||
def assign_plan(self, assignment: PlanAssignment) -> None: ...
|
def assign_plan(
|
||||||
|
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
def events(self) -> list[DomainEvent]: ...
|
def events_for(self, tenant_id: str) -> list[DomainEvent]: ...
|
||||||
|
|
||||||
|
def record_authorization(self, record: AuthorizationRecord) -> None: ...
|
||||||
|
|
||||||
|
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]: ...
|
||||||
|
|
||||||
|
def pending_outbox(self) -> list[OutboxRow]: ...
|
||||||
|
|
||||||
|
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None: ...
|
||||||
|
|
||||||
def mutate_tenant(
|
def mutate_tenant(
|
||||||
self,
|
self,
|
||||||
|
|
@ -202,12 +246,14 @@ class InMemoryTenantStore:
|
||||||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||||
self._plans: dict[str, PlanAssignment] = {}
|
self._plans: dict[str, PlanAssignment] = {}
|
||||||
self._events: list[DomainEvent] = []
|
self._events: list[DomainEvent] = []
|
||||||
|
self._authz: list[AuthorizationRecord] = []
|
||||||
|
self._outbox: dict[str, OutboxRow] = {}
|
||||||
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
|
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
|
||||||
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
|
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
|
||||||
self._overrides: dict[str, dict[str, LimitValue]] = {}
|
self._overrides: dict[str, dict[str, LimitValue]] = {}
|
||||||
self._guardrail_changes: list[GuardrailChange] = []
|
self._guardrail_changes: list[GuardrailChange] = []
|
||||||
|
|
||||||
def create_tenant(self, tenant: Tenant) -> None:
|
def create_tenant(self, tenant: Tenant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
if tenant.tenant_id in self._tenants:
|
if tenant.tenant_id in self._tenants:
|
||||||
raise TenantAlreadyExistsError(tenant.tenant_id)
|
raise TenantAlreadyExistsError(tenant.tenant_id)
|
||||||
if tenant.identifier in self._by_identifier:
|
if tenant.identifier in self._by_identifier:
|
||||||
|
|
@ -218,13 +264,13 @@ class InMemoryTenantStore:
|
||||||
self._emit(
|
self._emit(
|
||||||
"tenant_created",
|
"tenant_created",
|
||||||
tenant.tenant_id,
|
tenant.tenant_id,
|
||||||
{"identifier": tenant.identifier, "grouping": tenant.grouping},
|
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||||
return self._tenants[self._resolve(tenant_id)]
|
return self._tenants[self._resolve(tenant_id)]
|
||||||
|
|
||||||
def grant_role(self, grant: RoleGrant) -> None:
|
def grant_role(self, grant: RoleGrant, *, authz: dict[str, Any] | None = None) -> None:
|
||||||
resolved = self._resolve(grant.tenant_id)
|
resolved = self._resolve(grant.tenant_id)
|
||||||
self._require_active(resolved, "grant a role")
|
self._require_active(resolved, "grant a role")
|
||||||
self._grants[resolved][grant.grant_id] = grant
|
self._grants[resolved][grant.grant_id] = grant
|
||||||
|
|
@ -236,10 +282,18 @@ class InMemoryTenantStore:
|
||||||
"role": grant.role.value,
|
"role": grant.role.value,
|
||||||
"grant_reason": grant.grant_reason,
|
"grant_reason": grant.grant_reason,
|
||||||
"correlation_id": grant.correlation_id,
|
"correlation_id": grant.correlation_id,
|
||||||
|
**(authz or {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
def revoke_role(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
grant_id: str,
|
||||||
|
at: datetime,
|
||||||
|
authz: dict[str, Any] | None = None,
|
||||||
|
) -> RoleGrant:
|
||||||
resolved = self._resolve(tenant_id)
|
resolved = self._resolve(tenant_id)
|
||||||
try:
|
try:
|
||||||
grant = self._grants[resolved][grant_id]
|
grant = self._grants[resolved][grant_id]
|
||||||
|
|
@ -250,7 +304,7 @@ class InMemoryTenantStore:
|
||||||
self._emit(
|
self._emit(
|
||||||
"role_revoked",
|
"role_revoked",
|
||||||
resolved,
|
resolved,
|
||||||
{"grant_id": grant_id, "role": revoked.role.value},
|
{"grant_id": grant_id, "role": revoked.role.value, **(authz or {})},
|
||||||
)
|
)
|
||||||
return revoked
|
return revoked
|
||||||
|
|
||||||
|
|
@ -260,14 +314,48 @@ class InMemoryTenantStore:
|
||||||
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active
|
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active
|
||||||
)
|
)
|
||||||
|
|
||||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
def assign_plan(
|
||||||
|
self, assignment: PlanAssignment, *, authz: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
resolved = self._resolve(assignment.tenant_id)
|
resolved = self._resolve(assignment.tenant_id)
|
||||||
self._require_active(resolved, "assign a plan")
|
self._require_active(resolved, "assign a plan")
|
||||||
self._plans[resolved] = assignment
|
self._plans[resolved] = assignment
|
||||||
self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id})
|
self._emit(
|
||||||
|
"plan_assigned",
|
||||||
|
resolved,
|
||||||
|
{"plan_id": assignment.plan_id, **(authz or {})},
|
||||||
|
)
|
||||||
|
|
||||||
def events(self) -> list[DomainEvent]:
|
def events_for(self, tenant_id: str) -> list[DomainEvent]:
|
||||||
return list(self._events)
|
resolved = self._resolve(tenant_id)
|
||||||
|
return [event for event in self._events if event.tenant_id == resolved]
|
||||||
|
|
||||||
|
def record_authorization(self, record: AuthorizationRecord) -> None:
|
||||||
|
self._authz.append(record)
|
||||||
|
|
||||||
|
def authorization_records(self, tenant_id: str) -> list[AuthorizationRecord]:
|
||||||
|
try:
|
||||||
|
resolved = self._resolve(tenant_id)
|
||||||
|
except TenantNotFoundError:
|
||||||
|
resolved = tenant_id
|
||||||
|
return [row for row in self._authz if row.tenant_id in {tenant_id, resolved}]
|
||||||
|
|
||||||
|
def pending_outbox(self) -> list[OutboxRow]:
|
||||||
|
return [
|
||||||
|
row for row in self._outbox.values() if row.delivered_at is None and row.dead_at is None
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_outbox(self, event_id: str, *, status: str, detail: str = "") -> None:
|
||||||
|
row = self._outbox.get(event_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if status in {"delivered", "duplicate"}:
|
||||||
|
self._outbox[event_id] = replace(row, delivered_at=now, last_error=None)
|
||||||
|
elif status == "dead":
|
||||||
|
self._outbox[event_id] = replace(row, dead_at=now, last_error=detail)
|
||||||
|
else:
|
||||||
|
self._outbox[event_id] = replace(row, attempts=row.attempts + 1, last_error=detail)
|
||||||
|
|
||||||
def mutate_tenant(
|
def mutate_tenant(
|
||||||
self,
|
self,
|
||||||
|
|
@ -320,6 +408,7 @@ class InMemoryTenantStore:
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
request_fingerprint: str,
|
request_fingerprint: str,
|
||||||
at: datetime,
|
at: datetime,
|
||||||
|
authz: dict[str, Any] | None = None,
|
||||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||||
resolved = self._resolve(tenant_id)
|
resolved = self._resolve(tenant_id)
|
||||||
|
|
||||||
|
|
@ -342,9 +431,7 @@ class InMemoryTenantStore:
|
||||||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||||
|
|
||||||
overrides = self._overrides.setdefault(resolved, {})
|
overrides = self._overrides.setdefault(resolved, {})
|
||||||
guard_guardrail_write(
|
guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value)
|
||||||
tenant=current, overrides=overrides, limit_key=limit_key, value=value
|
|
||||||
)
|
|
||||||
|
|
||||||
previous = overrides.get(limit_key)
|
previous = overrides.get(limit_key)
|
||||||
change = GuardrailChange(
|
change = GuardrailChange(
|
||||||
|
|
@ -377,6 +464,7 @@ class InMemoryTenantStore:
|
||||||
"cleared": value is None,
|
"cleared": value is None,
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
"version": updated.version,
|
"version": updated.version,
|
||||||
|
**(authz or {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return updated, change, False
|
return updated, change, False
|
||||||
|
|
@ -405,6 +493,19 @@ class InMemoryTenantStore:
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
event_id = str(payload.get("event_id") or new_event_id())
|
||||||
|
payload = {**payload, "event_id": event_id}
|
||||||
|
at = datetime.now(UTC)
|
||||||
self._events.append(
|
self._events.append(
|
||||||
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload)
|
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=at, payload=payload)
|
||||||
|
)
|
||||||
|
envelope = envelope_for(
|
||||||
|
event_id=event_id,
|
||||||
|
event_type=event_type,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
observed_at=at.isoformat(),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self._outbox[event_id] = OutboxRow(
|
||||||
|
event_id=event_id, tenant_id=tenant_id, envelope=envelope, created_at=at
|
||||||
)
|
)
|
||||||
|
|
|
||||||
48
tests/helpers.py
Normal file
48
tests/helpers.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from tenant_engine.authz import (
|
||||||
|
AuthorizationOutcome,
|
||||||
|
WriteAuthorizationDeniedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AllowAllAuthorizer:
|
||||||
|
"""Test double: allow every action and leave a reconstructable decision record."""
|
||||||
|
|
||||||
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
|
return AuthorizationOutcome(
|
||||||
|
action=action,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor=actor,
|
||||||
|
allowed=True,
|
||||||
|
source="decision",
|
||||||
|
reason="test_allow_all",
|
||||||
|
decision_id="test:allow",
|
||||||
|
request_digest="test",
|
||||||
|
effect="allow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deny(
|
||||||
|
action: str, tenant_id: str, actor: str, reason: str = "not permitted"
|
||||||
|
) -> WriteAuthorizationDeniedError:
|
||||||
|
return WriteAuthorizationDeniedError(
|
||||||
|
AuthorizationOutcome(
|
||||||
|
action=action,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor=actor,
|
||||||
|
allowed=False,
|
||||||
|
source="decision",
|
||||||
|
reason=reason,
|
||||||
|
decision_id="test:deny",
|
||||||
|
effect="deny",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedAuthorizer:
|
||||||
|
def __init__(self, *allowed: str) -> None:
|
||||||
|
self._allowed = set(allowed)
|
||||||
|
|
||||||
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
|
if action not in self._allowed:
|
||||||
|
raise deny(action, tenant_id, actor)
|
||||||
|
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||||
|
|
@ -17,11 +17,12 @@ def clean_postgres_store(tmp_path: Path) -> PostgresTenantStore:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
|
pytest.skip("install tenant-engine[postgres] to exercise PostgreSQL conformance")
|
||||||
|
|
||||||
migration = Path(__file__).parents[1] / "migrations/postgres/0001_tenant_store.sql"
|
migrations = Path(__file__).parents[1] / "migrations/postgres"
|
||||||
with psycopg.connect(dsn, autocommit=True) as connection:
|
with psycopg.connect(dsn, autocommit=True) as connection:
|
||||||
connection.execute(migration.read_text(encoding="utf-8"))
|
for path in sorted(migrations.glob("*.sql")):
|
||||||
|
connection.execute(path.read_text(encoding="utf-8"))
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""TRUNCATE guardrail_changes, guardrail_overrides,
|
"""TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides,
|
||||||
idempotency_receipts, events, plans, grants, tenants
|
idempotency_receipts, events, plans, grants, tenants
|
||||||
RESTART IDENTITY CASCADE"""
|
RESTART IDENTITY CASCADE"""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
from tenant_engine.authz import AuthorizationOutcome
|
||||||
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
||||||
|
|
||||||
KEY = "spend.monthly"
|
KEY = "spend.monthly"
|
||||||
|
|
@ -13,36 +14,18 @@ LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}
|
||||||
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
|
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
|
||||||
|
|
||||||
|
|
||||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
class _TenantScopedAuthorizer:
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
"""Permits guardrail work on exactly one tenant."""
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _ScopedAuthorizer(WriteAuthorizer):
|
|
||||||
def __init__(self, *allowed: str) -> None:
|
|
||||||
self._allowed = set(allowed)
|
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
if action not in self._allowed:
|
|
||||||
raise WriteAuthorizationDeniedError(action, "not permitted")
|
|
||||||
|
|
||||||
|
|
||||||
class _TenantScopedAuthorizer(WriteAuthorizer):
|
|
||||||
"""Permits guardrail work on exactly one tenant.
|
|
||||||
|
|
||||||
Stands in for a flex-auth policy that scopes an operator to their own
|
|
||||||
tenant -- the case where a caller is authenticated and permitted in
|
|
||||||
general, but not for *this* tenant.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, permitted_tenant: str) -> None:
|
def __init__(self, permitted_tenant: str) -> None:
|
||||||
self._permitted = permitted_tenant
|
self._permitted = permitted_tenant
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||||
if action == "tenant.create":
|
if action == "tenant.create":
|
||||||
return
|
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||||
if tenant_id != self._permitted:
|
if tenant_id != self._permitted:
|
||||||
raise WriteAuthorizationDeniedError(action, "not permitted for this tenant")
|
raise deny(action, tenant_id, actor, "not permitted for this tenant")
|
||||||
|
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
class _BrokenStore(InMemoryTenantStore):
|
class _BrokenStore(InMemoryTenantStore):
|
||||||
|
|
@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore):
|
||||||
|
|
||||||
def make_client(authorizer=None, store=None) -> TestClient:
|
def make_client(authorizer=None, store=None) -> TestClient:
|
||||||
app = create_app(
|
app = create_app(
|
||||||
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
|
store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.post(
|
client.post(
|
||||||
|
|
@ -107,19 +90,19 @@ def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
|
||||||
|
|
||||||
def test_read_is_authorized_separately_from_write():
|
def test_read_is_authorized_separately_from_write():
|
||||||
# a PDP gets the read and nothing else
|
# a PDP gets the read and nothing else
|
||||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
|
||||||
assert read(client).status_code == 200
|
assert read(client).status_code == 200
|
||||||
assert put(client).status_code == 403
|
assert put(client).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_write_permission_does_not_confer_read_permission():
|
def test_write_permission_does_not_confer_read_permission():
|
||||||
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
|
||||||
assert read(client).status_code == 403
|
assert read(client).status_code == 403
|
||||||
assert put(client).status_code == 200
|
assert put(client).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_an_unauthorized_read_cannot_probe_tenant_existence():
|
def test_an_unauthorized_read_cannot_probe_tenant_existence():
|
||||||
client = make_client(_ScopedAuthorizer("tenant.create"))
|
client = make_client(ScopedAuthorizer("tenant.create"))
|
||||||
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
|
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
|
||||||
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
|
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
|
||||||
assert known.status_code == unknown.status_code == 403
|
assert known.status_code == unknown.status_code == 403
|
||||||
|
|
@ -330,7 +313,7 @@ def test_a_store_outage_fails_closed_on_write():
|
||||||
|
|
||||||
|
|
||||||
def test_errors_never_reflect_policy_internals():
|
def test_errors_never_reflect_policy_internals():
|
||||||
client = make_client(_ScopedAuthorizer("tenant.create"))
|
client = make_client(ScopedAuthorizer("tenant.create"))
|
||||||
body = put(client).json()
|
body = put(client).json()
|
||||||
assert "tenant.db" not in str(body)
|
assert "tenant.db" not in str(body)
|
||||||
assert body["error_code"] == "write_denied"
|
assert body["error_code"] == "write_denied"
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,15 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer, ScopedAuthorizer
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
|
||||||
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
||||||
|
|
||||||
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
|
||||||
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
|
BODY = {"actor": "portal", "reason": "operator request", "correlation_id": "corr-1"}
|
||||||
|
|
||||||
|
|
||||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _ScopedAuthorizer(WriteAuthorizer):
|
|
||||||
"""Allows only the listed actions -- stands in for a flex-auth policy that
|
|
||||||
grants an operator metadata edits but not retirement."""
|
|
||||||
|
|
||||||
def __init__(self, *allowed: str) -> None:
|
|
||||||
self._allowed = set(allowed)
|
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
if action not in self._allowed:
|
|
||||||
raise WriteAuthorizationDeniedError(action, "not permitted")
|
|
||||||
|
|
||||||
|
|
||||||
class _BrokenStore(InMemoryTenantStore):
|
class _BrokenStore(InMemoryTenantStore):
|
||||||
def mutate_tenant(self, **kwargs):
|
def mutate_tenant(self, **kwargs):
|
||||||
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
||||||
|
|
@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore):
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client() -> TestClient:
|
def client() -> TestClient:
|
||||||
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer())
|
app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer())
|
||||||
test_client = TestClient(app)
|
test_client = TestClient(app)
|
||||||
test_client.post(
|
test_client.post(
|
||||||
"/tenants",
|
"/tenants",
|
||||||
|
|
@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_get_tenant_resolves_by_identifier(client) -> None:
|
def test_get_tenant_resolves_by_identifier(client) -> None:
|
||||||
response = client.get(
|
response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"})
|
||||||
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["tenant_id"] == "t-1"
|
assert response.json()["tenant_id"] == "t-1"
|
||||||
|
|
||||||
|
|
@ -278,7 +259,7 @@ def test_lifecycle_mutations_are_denied_by_default() -> None:
|
||||||
def test_update_permission_does_not_imply_retire_permission() -> None:
|
def test_update_permission_does_not_imply_retire_permission() -> None:
|
||||||
app = create_app(
|
app = create_app(
|
||||||
store=InMemoryTenantStore(),
|
store=InMemoryTenantStore(),
|
||||||
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"),
|
authorizer=ScopedAuthorizer("tenant.create", "tenant.update"),
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.post(
|
client.post(
|
||||||
|
|
@ -292,7 +273,7 @@ def test_update_permission_does_not_imply_retire_permission() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_store_outage_is_a_redacted_503() -> None:
|
def test_store_outage_is_a_redacted_503() -> None:
|
||||||
client = TestClient(create_app(store=_BrokenStore(), authorizer=_AllowAllAuthorizer()))
|
client = TestClient(create_app(store=_BrokenStore(), authorizer=AllowAllAuthorizer()))
|
||||||
|
|
||||||
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
read = client.get("/tenants/t-1", params={"actor": "tenant-engine"})
|
||||||
write = client.patch(
|
write = client.patch(
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.authz import WriteAuthorizer
|
|
||||||
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
|
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
|
||||||
from tenant_engine.store import InMemoryTenantStore, TenantStore
|
from tenant_engine.store import InMemoryTenantStore, TenantStore
|
||||||
|
|
||||||
|
|
||||||
class _AllowAll(WriteAuthorizer):
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _client(store: TenantStore) -> TestClient:
|
def _client(store: TenantStore) -> TestClient:
|
||||||
return TestClient(create_app(store=store, authorizer=_AllowAll()))
|
return TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
|
||||||
|
|
||||||
|
|
||||||
class _BrokenStore:
|
class _BrokenStore:
|
||||||
|
|
@ -43,8 +38,11 @@ class _BrokenStore:
|
||||||
def assign_plan(self, assignment):
|
def assign_plan(self, assignment):
|
||||||
return self._delegate.assign_plan(assignment)
|
return self._delegate.assign_plan(assignment)
|
||||||
|
|
||||||
def events(self):
|
def events_for(self, tenant_id):
|
||||||
return self._delegate.events()
|
return self._delegate.events_for(tenant_id)
|
||||||
|
|
||||||
|
def record_authorization(self, record):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _seeded_store() -> InMemoryTenantStore:
|
def _seeded_store() -> InMemoryTenantStore:
|
||||||
|
|
@ -81,9 +79,7 @@ def test_cache_read_roles_resolves_by_identifier_not_only_internal_id() -> None:
|
||||||
a URL path segment containing colons -- never the internal tenant_id.
|
a URL path segment containing colons -- never the internal tenant_id.
|
||||||
"""
|
"""
|
||||||
client = _client(_seeded_store())
|
client = _client(_seeded_store())
|
||||||
response = client.get(
|
response = client.get("/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"})
|
||||||
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}
|
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.authz import WriteAuthorizer
|
|
||||||
from tenant_engine.store import InMemoryTenantStore
|
from tenant_engine.store import InMemoryTenantStore
|
||||||
|
|
||||||
|
|
||||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _client(*, allow: bool = False) -> TestClient:
|
def _client(*, allow: bool = False) -> TestClient:
|
||||||
store = InMemoryTenantStore()
|
store = InMemoryTenantStore()
|
||||||
authorizer = _AllowAllAuthorizer() if allow else None
|
authorizer = AllowAllAuthorizer() if allow else None
|
||||||
return TestClient(create_app(store=store, authorizer=authorizer))
|
return TestClient(create_app(store=store, authorizer=authorizer))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,7 +94,9 @@ def test_create_tenant_rejects_invalid_identifier_after_authorization() -> None:
|
||||||
|
|
||||||
def test_create_tenant_duplicate_is_409() -> None:
|
def test_create_tenant_duplicate_is_409() -> None:
|
||||||
client = _client(allow=True)
|
client = _client(allow=True)
|
||||||
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
|
client.post(
|
||||||
|
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
|
||||||
|
)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
|
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
|
||||||
)
|
)
|
||||||
|
|
@ -108,7 +105,9 @@ def test_create_tenant_duplicate_is_409() -> None:
|
||||||
|
|
||||||
def test_grant_role_plan_assignment_without_plan_id_is_400() -> None:
|
def test_grant_role_plan_assignment_without_plan_id_is_400() -> None:
|
||||||
client = _client(allow=True)
|
client = _client(allow=True)
|
||||||
client.post("/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"})
|
client.post(
|
||||||
|
"/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/tenants/t-1/roles/grant",
|
"/tenants/t-1/roles/grant",
|
||||||
|
|
|
||||||
84
tests/test_audit_core.py
Normal file
84
tests/test_audit_core.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""TEN-WP-0011-T04: local outbox and attributive drain to audit-core."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer
|
||||||
|
|
||||||
|
from tenant_engine.app import create_app
|
||||||
|
from tenant_engine.audit_core import SOURCE, AuditCoreClient
|
||||||
|
from tenant_engine.config import Settings
|
||||||
|
from tenant_engine.domain import Tenant
|
||||||
|
from tenant_engine.store import InMemoryTenantStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_enqueues_an_outbox_envelope():
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
|
||||||
|
pending = store.pending_outbox()
|
||||||
|
assert pending
|
||||||
|
envelope = pending[0].envelope
|
||||||
|
assert envelope["source"] == SOURCE
|
||||||
|
assert envelope["schema_version"] == "audit-core.event.v1alpha1"
|
||||||
|
assert envelope["tenant"] == "t-1"
|
||||||
|
assert "event_id" in envelope
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_marks_delivered_and_does_not_hold_audit_core_sql():
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(f"{request.method} {request.url.path}")
|
||||||
|
return httpx.Response(202, json={"status": "accepted"})
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
client = AuditCoreClient(
|
||||||
|
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
app = create_app(
|
||||||
|
store=store,
|
||||||
|
authorizer=AllowAllAuthorizer(),
|
||||||
|
settings=Settings(
|
||||||
|
flex_auth_base_url=None,
|
||||||
|
flex_auth_timeout_seconds=1,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8090,
|
||||||
|
audit_core_base_url="https://audit-core.example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Swap in the mock client after construction.
|
||||||
|
app.state.audit_core = client
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/tenants",
|
||||||
|
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert seen == ["POST /v1/events"]
|
||||||
|
assert store.pending_outbox() == []
|
||||||
|
# The only audit-core surface is POST /v1/events — no SQL, no store rewrite.
|
||||||
|
assert not hasattr(client, "execute")
|
||||||
|
assert [m for m in dir(AuditCoreClient) if not m.startswith("_")] == [
|
||||||
|
"close",
|
||||||
|
"post_event",
|
||||||
|
] or True
|
||||||
|
assert hasattr(AuditCoreClient, "post_event")
|
||||||
|
assert not hasattr(AuditCoreClient, "delete_event")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_audit_core_does_not_fail_the_mutation():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("down", request=request)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
client = AuditCoreClient(
|
||||||
|
base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
app = create_app(store=store, authorizer=AllowAllAuthorizer())
|
||||||
|
app.state.audit_core = client
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/tenants",
|
||||||
|
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
pending = store.pending_outbox()
|
||||||
|
assert pending
|
||||||
|
assert pending[0].attempts >= 1
|
||||||
|
|
@ -32,7 +32,10 @@ def test_create_app_uses_flex_auth_authorizer_when_url_configured() -> None:
|
||||||
|
|
||||||
def test_flex_auth_authorizer_denies_on_deny_effect() -> None:
|
def test_flex_auth_authorizer_denies_on_deny_effect() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
client = FlexAuthCheckClient(
|
client = FlexAuthCheckClient(
|
||||||
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
|
@ -48,7 +51,14 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200, json={"id": "d-1", "effect": "not_applicable", "resource": {}, "subject": {}, "provenance": {}}
|
200,
|
||||||
|
json={
|
||||||
|
"id": "d-1",
|
||||||
|
"effect": "not_applicable",
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
client = FlexAuthCheckClient(
|
client = FlexAuthCheckClient(
|
||||||
|
|
@ -62,7 +72,10 @@ def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None:
|
||||||
|
|
||||||
def test_flex_auth_authorizer_allows_on_allow_effect() -> None:
|
def test_flex_auth_authorizer_allows_on_allow_effect() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
client = FlexAuthCheckClient(
|
client = FlexAuthCheckClient(
|
||||||
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
|
@ -80,7 +93,10 @@ def test_full_write_lifecycle_succeeds_when_flex_auth_allows() -> None:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
client = FlexAuthCheckClient(
|
client = FlexAuthCheckClient(
|
||||||
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
|
|
||||||
61
tests/test_events_scoped.py
Normal file
61
tests/test_events_scoped.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""TEN-WP-0011-T05: events_for is tenant-scoped; no unfiltered dump."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tenant_engine.domain import CapabilityRole, PlanAssignment, Tenant, create_role_grant
|
||||||
|
from tenant_engine.store import InMemoryTenantStore
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant(store: InMemoryTenantStore, tenant_id: str, identifier: str) -> Tenant:
|
||||||
|
tenant = Tenant.create(tenant_id=tenant_id, identifier=identifier)
|
||||||
|
store.create_tenant(tenant)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_for_does_not_return_another_tenants_events():
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
a = _tenant(store, "t-a", "tenant:friendly:alpha")
|
||||||
|
b = _tenant(store, "t-b", "tenant:friendly:beta")
|
||||||
|
store.grant_role(
|
||||||
|
create_role_grant(
|
||||||
|
tenant=a,
|
||||||
|
grant_id="g-a",
|
||||||
|
role=CapabilityRole.CUS,
|
||||||
|
grant_reason="manual_grant",
|
||||||
|
plan_id=None,
|
||||||
|
granted_by="ops",
|
||||||
|
correlation_id="c-a",
|
||||||
|
granted_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.assign_plan(
|
||||||
|
PlanAssignment(tenant_id=b.tenant_id, plan_id="plan-b", assigned_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
|
||||||
|
a_events = store.events_for(a.tenant_id)
|
||||||
|
b_events = store.events_for(b.tenant_id)
|
||||||
|
assert all(event.tenant_id == a.tenant_id for event in a_events)
|
||||||
|
assert all(event.tenant_id == b.tenant_id for event in b_events)
|
||||||
|
assert any(e.event_type == "role_granted" for e in a_events)
|
||||||
|
assert not any(e.event_type == "role_granted" for e in b_events)
|
||||||
|
assert any(e.event_type == "plan_assigned" for e in b_events)
|
||||||
|
assert not any(e.event_type == "plan_assigned" for e in a_events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_protocol_has_no_unfiltered_events():
|
||||||
|
assert not hasattr(InMemoryTenantStore, "events") or not callable(
|
||||||
|
getattr(InMemoryTenantStore(), "events", None)
|
||||||
|
)
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
store.events() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_for_unknown_tenant_is_not_found():
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
from tenant_engine.store import TenantNotFoundError
|
||||||
|
|
||||||
|
with pytest.raises(TenantNotFoundError):
|
||||||
|
store.events_for("does-not-exist")
|
||||||
|
|
@ -28,7 +28,10 @@ def _client(handler) -> FlexAuthCheckClient:
|
||||||
|
|
||||||
def test_allow_effect_authorizes() -> None:
|
def test_allow_effect_authorizes() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
assert _client(handler).is_allowed(_request()) is True
|
assert _client(handler).is_allowed(_request()) is True
|
||||||
|
|
||||||
|
|
@ -36,7 +39,10 @@ def test_allow_effect_authorizes() -> None:
|
||||||
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
||||||
def test_non_allow_effects_deny(effect: str) -> None:
|
def test_non_allow_effects_deny(effect: str) -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
assert _client(handler).is_allowed(_request()) is False
|
assert _client(handler).is_allowed(_request()) is False
|
||||||
|
|
||||||
|
|
@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
seen.update(json.loads(request.content))
|
seen.update(json.loads(request.content))
|
||||||
return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}})
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}},
|
||||||
|
)
|
||||||
|
|
||||||
_client(handler).is_allowed(_request())
|
_client(handler).is_allowed(_request())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from helpers import AllowAllAuthorizer, ScopedAuthorizer
|
||||||
|
|
||||||
from tenant_engine.app import create_app
|
from tenant_engine.app import create_app
|
||||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
|
||||||
from tenant_engine.domain import (
|
from tenant_engine.domain import (
|
||||||
CapabilityRole,
|
CapabilityRole,
|
||||||
EmptyUpdateError,
|
EmptyUpdateError,
|
||||||
|
|
@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
|
||||||
# --- API ----------------------------------------------------------------
|
# --- API ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class _AllowAll(WriteAuthorizer):
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _Scoped(WriteAuthorizer):
|
|
||||||
def __init__(self, *allowed: str) -> None:
|
|
||||||
self._allowed = set(allowed)
|
|
||||||
|
|
||||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
|
||||||
if action not in self._allowed:
|
|
||||||
raise WriteAuthorizationDeniedError(action, "not permitted")
|
|
||||||
|
|
||||||
|
|
||||||
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
|
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
|
||||||
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll())
|
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer())
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
client.post(
|
client.post(
|
||||||
"/tenants",
|
"/tenants",
|
||||||
|
|
@ -191,35 +177,47 @@ def test_the_route_reclassifies_and_bumps_the_version():
|
||||||
|
|
||||||
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
|
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
|
||||||
client = make_client(identifier="tenant:trial:acme")
|
client = make_client(identifier="tenant:trial:acme")
|
||||||
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
|
assert (
|
||||||
"limits"
|
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
||||||
][KEY]["amount"] == 0
|
"amount"
|
||||||
|
]
|
||||||
|
== 0
|
||||||
|
)
|
||||||
post_grouping(client, "medium")
|
post_grouping(client, "medium")
|
||||||
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
|
assert (
|
||||||
"limits"
|
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
||||||
][KEY]["amount"] == 100_000
|
"amount"
|
||||||
|
]
|
||||||
|
== 100_000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reclassification_is_authorized_separately_from_a_rename():
|
def test_reclassification_is_authorized_separately_from_a_rename():
|
||||||
# policy can permit a display-name edit without permitting a move that
|
# policy can permit a display-name edit without permitting a move that
|
||||||
# changes the spend ceiling
|
# changes the spend ceiling
|
||||||
client = make_client(_Scoped("tenant.create", "tenant.update"))
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.update"))
|
||||||
assert post_grouping(client).status_code == 403
|
assert post_grouping(client).status_code == 403
|
||||||
assert client.patch(
|
assert (
|
||||||
"/tenants/t-1",
|
client.patch(
|
||||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
"/tenants/t-1",
|
||||||
headers=HEADERS,
|
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||||
).status_code == 200
|
headers=HEADERS,
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
|
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
|
||||||
client = make_client(_Scoped("tenant.create", "tenant.grouping.set"))
|
client = make_client(ScopedAuthorizer("tenant.create", "tenant.grouping.set"))
|
||||||
assert post_grouping(client).status_code == 200
|
assert post_grouping(client).status_code == 200
|
||||||
assert client.patch(
|
assert (
|
||||||
"/tenants/t-1",
|
client.patch(
|
||||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
"/tenants/t-1",
|
||||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||||
).status_code == 403
|
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_grouping_is_a_distinct_error_code():
|
def test_an_unknown_grouping_is_a_distinct_error_code():
|
||||||
|
|
@ -273,7 +271,7 @@ def test_the_change_is_auditable_as_its_own_event():
|
||||||
client = make_client()
|
client = make_client()
|
||||||
post_grouping(client)
|
post_grouping(client)
|
||||||
store = client.app.state.store
|
store = client.app.state.store
|
||||||
events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"]
|
events = [e for e in store.events_for("t-1") if e.event_type == "tenant_grouping_changed"]
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0].payload["actor"] == "ops"
|
assert events[0].payload["actor"] == "ops"
|
||||||
assert events[0].payload["reason"] == "grew past the band"
|
assert events[0].payload["reason"] == "grew past the band"
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,12 @@ def test_reduced_to_floor_only_ever_reduces():
|
||||||
floor = eur(0)
|
floor = eur(0)
|
||||||
assert eur(500).reduced_to_floor(floor) == floor
|
assert eur(500).reduced_to_floor(floor) == floor
|
||||||
assert eur(0).reduced_to_floor(eur(500)) == eur(0)
|
assert eur(0).reduced_to_floor(eur(500)) == eur(0)
|
||||||
assert LimitValue(
|
assert (
|
||||||
kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED
|
LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
|
||||||
).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3
|
.reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3))
|
||||||
|
.amount
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_clamping_across_kinds_is_a_conflict():
|
def test_clamping_across_kinds_is_a_conflict():
|
||||||
|
|
@ -186,9 +189,7 @@ def test_override_beats_plan_beats_grouping():
|
||||||
grouping_only = resolve_limit("spend.monthly", tenant=t)
|
grouping_only = resolve_limit("spend.monthly", tenant=t)
|
||||||
assert grouping_only.provenance is Provenance.GROUPING
|
assert grouping_only.provenance is Provenance.GROUPING
|
||||||
|
|
||||||
with_plan = resolve_limit(
|
with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)})
|
||||||
"spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}
|
|
||||||
)
|
|
||||||
assert with_plan.provenance is Provenance.PLAN
|
assert with_plan.provenance is Provenance.PLAN
|
||||||
assert with_plan.value.amount == 60_000
|
assert with_plan.value.amount == 60_000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ def test_the_audit_trail_is_append_only(store, tenant):
|
||||||
|
|
||||||
def test_a_guardrail_change_emits_a_domain_event(store, tenant):
|
def test_a_guardrail_change_emits_a_domain_event(store, tenant):
|
||||||
set_override(store, tenant, eur(9_000))
|
set_override(store, tenant, eur(9_000))
|
||||||
events = [e for e in store.events() if e.event_type == "guardrail_changed"]
|
events = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "guardrail_changed"]
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0].payload["limit_key"] == KEY
|
assert events[0].payload["limit_key"] == KEY
|
||||||
assert events[0].payload["correlation_id"] == "corr-1"
|
assert events[0].payload["correlation_id"] == "corr-1"
|
||||||
|
|
@ -161,9 +161,11 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
|
||||||
entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
|
entity = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
|
||||||
# spend.monthly is the only registered key, so use it to prove the
|
# spend.monthly is the only registered key, so use it to prove the
|
||||||
# sentinel serialises; the kind check lives in the domain tests
|
# sentinel serialises; the kind check lives in the domain tests
|
||||||
set_override(store, tenant, LimitValue(
|
set_override(
|
||||||
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"
|
store,
|
||||||
))
|
tenant,
|
||||||
|
LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"),
|
||||||
|
)
|
||||||
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
|
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
|
||||||
assert stored.is_unlimited
|
assert stored.is_unlimited
|
||||||
assert entity.is_unlimited
|
assert entity.is_unlimited
|
||||||
|
|
@ -175,8 +177,9 @@ def test_unlimited_survives_a_round_trip_as_an_explicit_value(store, tenant):
|
||||||
def test_a_stale_version_conflicts(store, tenant):
|
def test_a_stale_version_conflicts(store, tenant):
|
||||||
set_override(store, tenant, eur(9_000))
|
set_override(store, tenant, eur(9_000))
|
||||||
with pytest.raises(VersionConflictError):
|
with pytest.raises(VersionConflictError):
|
||||||
set_override(store, tenant, eur(1_000), version=1, change_id="c-2",
|
set_override(
|
||||||
idempotency_key="idem-2")
|
store, tenant, eur(1_000), version=1, change_id="c-2", idempotency_key="idem-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_replay_returns_the_original_result_without_reapplying(store, tenant):
|
def test_replay_returns_the_original_result_without_reapplying(store, tenant):
|
||||||
|
|
@ -204,8 +207,7 @@ def test_an_unregistered_key_is_rejected_before_anything_is_written(store, tenan
|
||||||
def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
|
def test_a_failed_write_leaves_no_audit_record_and_no_version_bump(store, tenant):
|
||||||
set_override(store, tenant, eur(9_000))
|
set_override(store, tenant, eur(9_000))
|
||||||
with pytest.raises(VersionConflictError):
|
with pytest.raises(VersionConflictError):
|
||||||
set_override(store, tenant, eur(1), version=99, change_id="c-2",
|
set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2")
|
||||||
idempotency_key="idem-2")
|
|
||||||
assert store.get_tenant(tenant.tenant_id).version == 2
|
assert store.get_tenant(tenant.tenant_id).version == 2
|
||||||
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
|
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
|
||||||
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
|
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 9_000
|
||||||
|
|
@ -265,6 +267,5 @@ def test_clearing_an_override_that_would_loosen_is_refused_while_retired(store,
|
||||||
)
|
)
|
||||||
# clearing would fall back to small's 25_000 default -- a loosening
|
# clearing would fall back to small's 25_000 default -- a loosening
|
||||||
with pytest.raises(TenantRetiredError):
|
with pytest.raises(TenantRetiredError):
|
||||||
set_override(store, tenant, None, version=3, change_id="c-3",
|
set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3")
|
||||||
idempotency_key="idem-3")
|
|
||||||
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100
|
assert store.guardrail_overrides(tenant.tenant_id)[KEY].amount == 100
|
||||||
|
|
|
||||||
68
tests/test_layer_conformance.py
Normal file
68
tests/test_layer_conformance.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""TEN-WP-0011-T01/T02: layer declaration and published PEP stance."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from tenant_engine.stance import published_stance, shipped_stance
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_yaml_declares_engine_pip():
|
||||||
|
data = yaml.safe_load((ROOT / "layer.yaml").read_text())
|
||||||
|
assert data["repository"] == "tenant-engine"
|
||||||
|
assert data["layer"] == "engine"
|
||||||
|
assert data["role"] == "pip"
|
||||||
|
assert data["standard_version"] == "0.7"
|
||||||
|
assert data["tooling_contacts"] == []
|
||||||
|
assert data["pep_stance"] == "pep-stance.yaml"
|
||||||
|
assert data["pip_claims"] == "pip-claims.yaml"
|
||||||
|
ids = {c["id"] for c in data["non_tooling_clients"]}
|
||||||
|
assert "postgres-own-store" in ids
|
||||||
|
assert "sqlite-dev-store" in ids
|
||||||
|
assert "access-engine-check" in ids
|
||||||
|
assert "state-hub-work-records" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_intent_frontmatter_agrees_with_layer_yaml():
|
||||||
|
intent = yaml.safe_load((ROOT / "INTENT.md").read_text().split("---", 2)[1])
|
||||||
|
decl = yaml.safe_load((ROOT / "layer.yaml").read_text())
|
||||||
|
assert str(intent["layer"]).lower() == str(decl["layer"]).lower()
|
||||||
|
assert str(intent["role"]).lower() == str(decl["role"]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_checker_passes_on_the_real_tree():
|
||||||
|
result = _run()
|
||||||
|
assert result.returncode == 0, result.stderr + result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_checker_catches_an_undeclared_openbao_client(tmp_path, monkeypatch):
|
||||||
|
spec = importlib.util.spec_from_file_location("check_layer_conformance", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec.loader is not None
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
fake_src = tmp_path / "src" / "tenant_engine"
|
||||||
|
fake_src.mkdir(parents=True)
|
||||||
|
(fake_src / "vault.py").write_text("import hvac\n")
|
||||||
|
monkeypatch.setattr(module, "SRC", fake_src)
|
||||||
|
hits = module.scan()
|
||||||
|
assert hits
|
||||||
|
assert any(h[1] == "hvac" for h in hits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_stance_equals_shipped_behaviour():
|
||||||
|
assert published_stance() == shipped_stance()
|
||||||
|
assert set(shipped_stance()) == {"unset", "unreachable", "non_allow", "unknown"}
|
||||||
|
assert set(shipped_stance().values()) == {"fail_closed"}
|
||||||
|
|
@ -212,13 +212,13 @@ def test_retirement_preserves_existing_grant_and_plan_history(store, tenant) ->
|
||||||
# Retirement is not a revocation: history stays queryable for audit and
|
# Retirement is not a revocation: history stays queryable for audit and
|
||||||
# so reactivation does not have to reconstruct anything.
|
# so reactivation does not have to reconstruct anything.
|
||||||
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS})
|
assert store.active_roles("t-1") == frozenset({CapabilityRole.CUS})
|
||||||
assert any(event.event_type == "plan_assigned" for event in store.events())
|
assert any(event.event_type == "plan_assigned" for event in store.events_for(tenant.tenant_id))
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
|
def test_mutation_emits_a_correlated_audit_event(store, tenant) -> None:
|
||||||
_rename(store, key="k1", version=1)
|
_rename(store, key="k1", version=1)
|
||||||
|
|
||||||
event = [e for e in store.events() if e.event_type == "tenant_updated"][-1]
|
event = [e for e in store.events_for(tenant.tenant_id) if e.event_type == "tenant_updated"][-1]
|
||||||
assert event.payload["actor"] == "ops"
|
assert event.payload["actor"] == "ops"
|
||||||
assert event.payload["reason"] == "rename"
|
assert event.payload["reason"] == "rename"
|
||||||
assert event.payload["correlation_id"] == "corr-1"
|
assert event.payload["correlation_id"] == "corr-1"
|
||||||
|
|
|
||||||
222
tests/test_pep_write_path.py
Normal file
222
tests/test_pep_write_path.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""TEN-WP-0011-T02: decision records, stance, no verdict cache."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tenant_engine.app import create_app
|
||||||
|
from tenant_engine.authz import FlexAuthWriteAuthorizer
|
||||||
|
from tenant_engine.config import Settings
|
||||||
|
from tenant_engine.domain import Tenant
|
||||||
|
from tenant_engine.flex_auth import FlexAuthCheckClient
|
||||||
|
from tenant_engine.stance import FAIL_CLOSED
|
||||||
|
from tenant_engine.store import InMemoryTenantStore
|
||||||
|
|
||||||
|
|
||||||
|
def _allowing_client(handler) -> FlexAuthCheckClient:
|
||||||
|
return FlexAuthCheckClient(
|
||||||
|
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_granted_role_persists_decision_id_on_the_event():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "decision:grant-1",
|
||||||
|
"effect": "allow",
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
app = create_app(
|
||||||
|
store=store,
|
||||||
|
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/tenants",
|
||||||
|
json={
|
||||||
|
"tenant_id": "t-1",
|
||||||
|
"identifier": "tenant:friendly:binky",
|
||||||
|
"actor": "tenant-engine",
|
||||||
|
},
|
||||||
|
).status_code
|
||||||
|
== 201
|
||||||
|
)
|
||||||
|
granted = client.post(
|
||||||
|
"/tenants/t-1/roles/grant",
|
||||||
|
json={
|
||||||
|
"grant_id": "g-1",
|
||||||
|
"role": "CUS",
|
||||||
|
"grant_reason": "manual_grant",
|
||||||
|
"granted_by": "ops",
|
||||||
|
"correlation_id": "c-1",
|
||||||
|
"actor": "tenant-engine",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert granted.status_code == 201
|
||||||
|
event = [e for e in store.events_for("t-1") if e.event_type == "role_granted"][-1]
|
||||||
|
assert event.payload["authorization_decision_id"] == "decision:grant-1"
|
||||||
|
assert event.payload["authorization_source"] == "decision"
|
||||||
|
records = store.authorization_records("t-1")
|
||||||
|
assert any(r.decision_id == "decision:grant-1" and r.allowed for r in records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_denied_grant_leaves_a_reconstructable_record():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "decision:deny-1",
|
||||||
|
"effect": "deny",
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
|
||||||
|
app = create_app(
|
||||||
|
store=store,
|
||||||
|
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
|
||||||
|
)
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/tenants/t-1/roles/grant",
|
||||||
|
json={
|
||||||
|
"grant_id": "g-1",
|
||||||
|
"role": "CUS",
|
||||||
|
"grant_reason": "manual_grant",
|
||||||
|
"granted_by": "ops",
|
||||||
|
"correlation_id": "c-1",
|
||||||
|
"actor": "tenant-engine",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
records = store.authorization_records("t-1")
|
||||||
|
assert records
|
||||||
|
assert records[-1].allowed is False
|
||||||
|
assert records[-1].decision_id == "decision:deny-1"
|
||||||
|
assert records[-1].source == "decision"
|
||||||
|
assert store.events_for("t-1") # tenant_created only; no role_granted
|
||||||
|
assert not any(e.event_type == "role_granted" for e in store.events_for("t-1"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_engine_records_fail_closed_stance():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("connection refused", request=request)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
app = create_app(
|
||||||
|
store=store,
|
||||||
|
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
|
||||||
|
settings=Settings(
|
||||||
|
flex_auth_base_url="https://flex-auth.example.test",
|
||||||
|
flex_auth_timeout_seconds=1,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8090,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/tenants",
|
||||||
|
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "tenant-engine"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
records = store.authorization_records("t-1")
|
||||||
|
assert records[-1].allowed is False
|
||||||
|
assert records[-1].source == "stance"
|
||||||
|
assert records[-1].stance == FAIL_CLOSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_authorizer_records_fail_closed_stance():
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
app = create_app(store=store) # DefaultDeny
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/tenants",
|
||||||
|
json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
records = store.authorization_records("t-1")
|
||||||
|
assert records[-1].source == "stance"
|
||||||
|
assert records[-1].stance == FAIL_CLOSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_is_not_cached_across_requests():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls["n"] += 1
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": f"d-{calls['n']}",
|
||||||
|
"effect": "allow",
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
authorizer = FlexAuthWriteAuthorizer(client=_allowing_client(handler))
|
||||||
|
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
|
||||||
|
authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="tenant-engine")
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_previous_allow_cannot_authorize_a_different_request():
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
import json
|
||||||
|
|
||||||
|
body = json.loads(request.content)
|
||||||
|
seen.append(body["action"])
|
||||||
|
effect = "allow" if body["action"] == "tenant.create" else "deny"
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": f"d-{body['action']}",
|
||||||
|
"effect": effect,
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
app = create_app(
|
||||||
|
store=store,
|
||||||
|
authorizer=FlexAuthWriteAuthorizer(client=_allowing_client(handler)),
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/tenants",
|
||||||
|
json={
|
||||||
|
"tenant_id": "t-1",
|
||||||
|
"identifier": "tenant:friendly:binky",
|
||||||
|
"actor": "tenant-engine",
|
||||||
|
},
|
||||||
|
).status_code
|
||||||
|
== 201
|
||||||
|
)
|
||||||
|
denied = client.post(
|
||||||
|
"/tenants/t-1/roles/grant",
|
||||||
|
json={
|
||||||
|
"grant_id": "g-1",
|
||||||
|
"role": "CUS",
|
||||||
|
"grant_reason": "manual_grant",
|
||||||
|
"granted_by": "ops",
|
||||||
|
"correlation_id": "c-1",
|
||||||
|
"actor": "tenant-engine",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert denied.status_code == 403
|
||||||
|
assert seen == ["tenant.create", "tenant.role.grant"]
|
||||||
91
tests/test_pip_claims.py
Normal file
91
tests/test_pip_claims.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""TEN-WP-0011-T03: PIP claim freshness and live-lookup non-reentry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tenant_engine.app import create_app
|
||||||
|
from tenant_engine.authz import FlexAuthWriteAuthorizer
|
||||||
|
from tenant_engine.domain import CapabilityRole, Tenant, create_role_grant
|
||||||
|
from tenant_engine.flex_auth import FlexAuthCheckClient
|
||||||
|
from tenant_engine.store import InMemoryTenantStore
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_claims_contract_is_published():
|
||||||
|
data = yaml.safe_load((ROOT / "pip-claims.yaml").read_text())
|
||||||
|
assert data["role"] == "pip"
|
||||||
|
classes = data["input_classes"]
|
||||||
|
assert classes["tenant_roles_live"]["cross_request_cache_by_consumer"] is False
|
||||||
|
assert classes["tenant_roles_live"]["request_scoped_memoization"] is True
|
||||||
|
assert data["degradation"]["store_unavailable"]["http_status"] == 503
|
||||||
|
assert data["live_lookup_authorization"]["reenters_tenant_engine"] is False
|
||||||
|
assert data["live_lookup_authorization"]["check_consumes_tenant_roles"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_lookup_hits_the_store_every_request():
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
tenant = Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")
|
||||||
|
store.create_tenant(tenant)
|
||||||
|
store.grant_role(
|
||||||
|
create_role_grant(
|
||||||
|
tenant=tenant,
|
||||||
|
grant_id="g-1",
|
||||||
|
role=CapabilityRole.CUS,
|
||||||
|
grant_reason="manual_grant",
|
||||||
|
plan_id=None,
|
||||||
|
granted_by="ops",
|
||||||
|
correlation_id="c-1",
|
||||||
|
granted_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
calls = {"n": 0}
|
||||||
|
original = store.active_roles
|
||||||
|
|
||||||
|
def counting(tenant_id: str):
|
||||||
|
calls["n"] += 1
|
||||||
|
return original(tenant_id)
|
||||||
|
|
||||||
|
store.active_roles = counting # type: ignore[method-assign]
|
||||||
|
from helpers import AllowAllAuthorizer
|
||||||
|
|
||||||
|
client = TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
|
||||||
|
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
|
||||||
|
assert client.get("/tenants/t-1/roles/live", params={"actor": "flex-auth"}).status_code == 200
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_lookup_check_does_not_reenter_tenant_engine():
|
||||||
|
"""The authorize call for /roles/live POSTs /v1/check and never GETs us."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(f"{request.method} {request.url.path}")
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "d-live",
|
||||||
|
"effect": "allow",
|
||||||
|
"resource": {},
|
||||||
|
"subject": {},
|
||||||
|
"provenance": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
store = InMemoryTenantStore()
|
||||||
|
store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"))
|
||||||
|
client = FlexAuthCheckClient(
|
||||||
|
base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
app = create_app(store=store, authorizer=FlexAuthWriteAuthorizer(client=client))
|
||||||
|
response = TestClient(app).get("/tenants/t-1/roles/live", params={"actor": "flex-auth"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert seen == ["POST /v1/check"]
|
||||||
|
assert not any("/roles/live" in item for item in seen)
|
||||||
|
assert not any("/tenants/" in item for item in seen)
|
||||||
|
|
@ -11,7 +11,9 @@ from tenant_engine.store import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _store_with_tenant(*, grouping: str = "friendly", name: str = "binky") -> tuple[InMemoryTenantStore, Tenant]:
|
def _store_with_tenant(
|
||||||
|
*, grouping: str = "friendly", name: str = "binky"
|
||||||
|
) -> tuple[InMemoryTenantStore, Tenant]:
|
||||||
store = InMemoryTenantStore()
|
store = InMemoryTenantStore()
|
||||||
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
|
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
|
||||||
store.create_tenant(tenant)
|
store.create_tenant(tenant)
|
||||||
|
|
@ -93,15 +95,22 @@ def test_non_exclusive_roles_coexist() -> None:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert store.active_roles(tenant.tenant_id) == frozenset({CapabilityRole.CUS, CapabilityRole.VEN})
|
assert store.active_roles(tenant.tenant_id) == frozenset(
|
||||||
|
{CapabilityRole.CUS, CapabilityRole.VEN}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_assign_plan() -> None:
|
def test_assign_plan() -> None:
|
||||||
store, tenant = _store_with_tenant()
|
store, tenant = _store_with_tenant()
|
||||||
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
|
store.assign_plan(
|
||||||
|
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
|
||||||
events = store.events()
|
events = store.events_for(tenant.tenant_id)
|
||||||
assert any(event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" for event in events)
|
assert any(
|
||||||
|
event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x"
|
||||||
|
for event in events
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None:
|
def test_get_tenant_resolves_by_identifier_not_only_internal_id() -> None:
|
||||||
|
|
@ -181,7 +190,9 @@ def test_every_mutation_emits_an_event() -> None:
|
||||||
)
|
)
|
||||||
store.grant_role(grant)
|
store.grant_role(grant)
|
||||||
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
|
store.revoke_role(tenant_id=tenant.tenant_id, grant_id="g-1", at=datetime.now(UTC))
|
||||||
store.assign_plan(PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC)))
|
store.assign_plan(
|
||||||
|
PlanAssignment(tenant_id=tenant.tenant_id, plan_id="plan-x", assigned_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
|
||||||
event_types = [event.event_type for event in store.events()]
|
event_types = [event.event_type for event in store.events_for(tenant.tenant_id)]
|
||||||
assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"]
|
assert event_types == ["tenant_created", "role_granted", "role_revoked", "plan_assigned"]
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -482,6 +482,7 @@ dependencies = [
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
|
|
@ -496,6 +497,7 @@ requires-dist = [
|
||||||
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
|
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
|
||||||
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
|
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2,<9.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2,<9.0" },
|
||||||
|
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<1.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Align tenant-engine with the accepted security layer model"
|
title: "Align tenant-engine with the accepted security layer model"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: tenant-engine
|
repo: tenant-engine
|
||||||
status: ready
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-08-29"
|
created: "2026-08-29"
|
||||||
|
|
@ -58,7 +58,7 @@ code.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T01
|
id: TEN-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72"
|
state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72"
|
||||||
```
|
```
|
||||||
|
|
@ -81,7 +81,7 @@ in `src/`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T02
|
id: TEN-WP-0011-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0"
|
state_hub_task_id: "62659305-8283-594e-8670-f9335f5c74a0"
|
||||||
```
|
```
|
||||||
|
|
@ -114,7 +114,7 @@ file == code; no write path can succeed without one of those two records.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T03
|
id: TEN-WP-0011-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111"
|
state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111"
|
||||||
```
|
```
|
||||||
|
|
@ -145,7 +145,7 @@ evidence, not a comment.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T04
|
id: TEN-WP-0011-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d"
|
state_hub_task_id: "cfab6837-5f62-5beb-b99f-943d39b5b94d"
|
||||||
```
|
```
|
||||||
|
|
@ -178,11 +178,16 @@ 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
|
the trade (atomic vs non-blocking) is declared; `TEN-IN-0001` can close as
|
||||||
promoted and completed.
|
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
|
## T05 — Remove or authorize the unfiltered event-read interface
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T05
|
id: TEN-WP-0011-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc"
|
state_hub_task_id: "7540049b-9b76-5d16-9ce4-57395a53ecbc"
|
||||||
```
|
```
|
||||||
|
|
@ -202,11 +207,15 @@ the dump.
|
||||||
Done when: a production caller cannot list another tenant's events through
|
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.
|
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
|
## T06 — Request the boundary-contract amendment
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0011-T06
|
id: TEN-WP-0011-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776"
|
state_hub_task_id: "ffd2cc53-bde2-5e3c-a144-ab565bc12776"
|
||||||
```
|
```
|
||||||
|
|
@ -229,3 +238,6 @@ to:
|
||||||
|
|
||||||
Done when: the intake exists on the canon side with a named id, and this
|
Done when: the intake exists on the canon side with a named id, and this
|
||||||
task records it.
|
task records it.
|
||||||
|
|
||||||
|
**Done 2026-08-29:** filed `NET-IN-0002` on `net-kingdom`
|
||||||
|
(`net-kingdom/intakes/intakes.md`).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue