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` |
|
||||
| Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical |
|
||||
| Staged promotion | **no** | `TEN-WP-0008` |
|
||||
| Machine-readable `layer.yaml` | **no** | Frontmatter declaration is in `INTENT.md` |
|
||||
| Persisted `authorization_decision_id` | **no** | Boundary contract requires it; `FlexAuthCheckClient` drops the envelope |
|
||||
| Published unreachable-engine stance map | **no** | Behaviour is fail-closed in code; companion §5 requires it published and tested equal to shipped behaviour |
|
||||
| Independent audit-core emission | **no** | Local `events` table only (`TEN-IN-0001`) |
|
||||
| Bounded event-read interface | **no** | In-process `TenantStore.events()` is unfiltered (`TEN-IN-0002`) |
|
||||
| Claim freshness / input-class lifetime | **no** | Token lifetime covers the cache-read path by profile convention; live-lookup has no declared deadline |
|
||||
| Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` |
|
||||
| Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) |
|
||||
| Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour |
|
||||
| Independent audit-core emission | path shipped, sender pending | Local outbox + POST `/v1/events`; `AUDIT-IN-0002` |
|
||||
| Bounded event-read interface | yes | `events_for(tenant_id)` only (`TEN-WP-0011-T05`) |
|
||||
| Claim freshness / input-class lifetime | yes | `pip-claims.yaml` (`TEN-WP-0011-T03`) |
|
||||
|
||||
The boundary contract still labels guardrail policy "reserved, not
|
||||
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`
|
||||
(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`).
|
||||
`tenant-engine` gates every write, and every authorized read, through
|
||||
flex-auth's `POST /v1/check` (`flex_auth.FlexAuthCheckClient`, wired in as
|
||||
`authz.FlexAuthWriteAuthorizer`). Writes are PEP-shaped: no mutation
|
||||
without a decision record or a recorded fail-closed stance
|
||||
(`pep-stance.yaml`).
|
||||
|
||||
## What tenant-engine sends
|
||||
|
||||
|
|
@ -13,56 +16,98 @@ A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`:
|
|||
"tenant": "tenant:friendly:binky",
|
||||
"subject": {"id": "<actor>", "type": "service"},
|
||||
"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
|
||||
`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge):
|
||||
|
||||
| Action | Resource type |
|
||||
| --- | --- |
|
||||
| `tenant.create` | `tenant` |
|
||||
| `tenant.role.grant` | `role-grant` |
|
||||
| `tenant.role.revoke` | `role-grant` |
|
||||
| `tenant.plan.assign` | `plan-assignment` |
|
||||
| Action | Resource type | Surface |
|
||||
| --- | --- | --- |
|
||||
| `tenant.create` | `tenant` | write |
|
||||
| `tenant.role.grant` | `role-grant` | write |
|
||||
| `tenant.role.revoke` | `role-grant` | write |
|
||||
| `tenant.plan.assign` | `plan-assignment` | write |
|
||||
| `tenant.update` | `tenant` | write |
|
||||
| `tenant.retire` | `tenant` | write |
|
||||
| `tenant.reactivate` | `tenant` | write |
|
||||
| `tenant.grouping.set` | `tenant` | write |
|
||||
| `tenant.guardrail.read` | `guardrail` | read |
|
||||
| `tenant.guardrail.set` | `guardrail` | write |
|
||||
| `tenant.read` | `tenant` | read |
|
||||
| `tenant.role.read` | `role-grant` | read (cache-read) |
|
||||
| `tenant.role.read.live` | `role-grant` | read (live-lookup) |
|
||||
|
||||
## What tenant-engine expects back
|
||||
|
||||
A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`.
|
||||
Only `effect: "allow"` authorizes the write. Every other `effect`
|
||||
(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a
|
||||
malformed body, or a transport failure/timeout all resolve to **deny** —
|
||||
`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it
|
||||
always returns a plain `bool`.
|
||||
Only `effect: "allow"` authorizes the action. Every other `effect`, a
|
||||
non-200 response, a malformed body, or a transport failure/timeout all
|
||||
resolve to **deny**. `FlexAuthCheckClient.check()` never raises past its
|
||||
own boundary; it returns a `CheckResult` that is either a decision or a
|
||||
recorded application of the fail-closed stance.
|
||||
|
||||
## The 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`
|
||||
lands (resource/action vocabulary + an authored policy package), **every
|
||||
check resolves to deny or not_applicable** — verified against real
|
||||
`flex-auth` behavior, not assumed. This is correct fail-closed behavior,
|
||||
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
|
||||
HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`,
|
||||
both over real HTTP between two processes) — see `TEN-WP-0003`'s closure
|
||||
notes for the exact commands.
|
||||
## Live-lookup is not cyclic (TEN-WP-0011-T03)
|
||||
|
||||
`GET /tenants/{id}/roles/live` authorizes via `tenant.role.read.live` then
|
||||
reads the store. That check does **not** re-enter tenant-engine:
|
||||
|
||||
1. The CheckRequest this engine sends has empty `context` and does not
|
||||
carry `tenant_roles`.
|
||||
2. `FlexAuthCheckClient` POSTs only `/v1/check`. Proven by
|
||||
`tests/test_pip_claims.py::test_live_lookup_check_does_not_reenter_tenant_engine`.
|
||||
3. flex-auth's tenant-engine policy package
|
||||
(`flex-auth/examples/tenant-engine/policy_package.md`) matches
|
||||
`subject.id` and `action` only. It does not consult tenant capability
|
||||
roles. The package's own scope note says those roles are tenant state
|
||||
a *different* protected system might consult via live-lookup;
|
||||
conflating the two would authorize the wrong thing.
|
||||
4. flex-auth's live-roles adapter is built and unwired
|
||||
(`FLEX-WP-0015`, `flex-auth/docs/tenancy-posture-review.md`): no
|
||||
non-test caller, no current policy consumes `tenant_roles` for a
|
||||
privileged path.
|
||||
|
||||
Together: GET /roles/live → POST /v1/check is a service-identity question.
|
||||
The PDP does not need tenant-engine claims to answer it, so it does not
|
||||
call back.
|
||||
|
||||
Note: `tenant.role.read` / `tenant.role.read.live` / `tenant.read` /
|
||||
`tenant.grouping.set` are not yet in that policy package's `valid_actions`.
|
||||
A live flex-auth will currently `unknown_action` those four. That is
|
||||
itself evidence they are not evaluated via tenant-role claims. Adding
|
||||
them as static service-identity rules (same shape as
|
||||
`tenant.guardrail.read`) is flex-auth work, not a cycle to unwind here.
|
||||
|
||||
## Unreachable-engine stance
|
||||
|
||||
Published in `pep-stance.yaml`. Every scope is `fail_closed`. DefaultDeny
|
||||
(URL unset) and transport failure both apply that stance and record it.
|
||||
Tests assert the file equals shipped behaviour.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer` — no writes ever succeed, which is the correct posture for local/test runs that have no `flex-auth` to call. |
|
||||
| `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_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer`. |
|
||||
| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous path — no retries. |
|
||||
| `TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE` | unset | Rotating bearer token file, read on each check. |
|
||||
|
||||
## 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`
|
||||
— the flex-auth-side work this client depends on for real `allow`
|
||||
decisions, and (separately) flex-auth's own live-lookup consumption of
|
||||
`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.
|
||||
- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md`
|
||||
- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ kind: intake
|
|||
title: "Externalize tenant-engine audit evidence to audit-core"
|
||||
lane: yellow
|
||||
status: closed
|
||||
outcome: promoted
|
||||
outcome: completed
|
||||
promoted_to: TEN-WP-0011-T04
|
||||
priority: high
|
||||
owner: tenant-engine
|
||||
|
|
@ -58,7 +58,7 @@ kind: intake
|
|||
title: "Remove or authorize the tenant-engine unfiltered event-read interface"
|
||||
lane: red
|
||||
status: closed
|
||||
outcome: promoted
|
||||
outcome: completed
|
||||
promoted_to: TEN-WP-0011-T05
|
||||
priority: high
|
||||
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 = [
|
||||
"pytest>=8.2,<9.0",
|
||||
"ruff>=0.6,<1.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
[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 tenant_engine import __version__
|
||||
from tenant_engine.audit_core import AuditCoreClient
|
||||
from tenant_engine.authz import (
|
||||
AuthorizationOutcome,
|
||||
DefaultDenyWriteAuthorizer,
|
||||
FlexAuthWriteAuthorizer,
|
||||
WriteAuthorizationDeniedError,
|
||||
|
|
@ -44,6 +46,7 @@ from tenant_engine.guardrail import (
|
|||
)
|
||||
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
|
||||
from tenant_engine.store import (
|
||||
AuthorizationRecord,
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
InMemoryTenantStore,
|
||||
|
|
@ -185,9 +188,11 @@ def create_app(
|
|||
app = FastAPI(title="tenant-engine", version=__version__)
|
||||
app.state.store = store
|
||||
app.state.authorizer = authorizer
|
||||
app.state.audit_core = _build_audit_client(settings)
|
||||
|
||||
@app.exception_handler(WriteAuthorizationDeniedError)
|
||||
async def handle_denied(_: Request, exc: WriteAuthorizationDeniedError) -> JSONResponse:
|
||||
_persist_authz(store, exc.outcome)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"error_code": "write_denied", "action": exc.action, "detail": exc.reason},
|
||||
|
|
@ -233,7 +238,7 @@ def create_app(
|
|||
response: Response,
|
||||
actor: str = Query(min_length=1),
|
||||
) -> 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:
|
||||
tenant = store.get_tenant(tenant_id)
|
||||
except TenantNotFoundError as exc:
|
||||
|
|
@ -249,7 +254,7 @@ def create_app(
|
|||
|
||||
@app.get("/tenants/{tenant_id}/roles")
|
||||
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)
|
||||
|
||||
# -- Live-lookup API (flex-auth, for aal2-class decisions) -----------
|
||||
|
|
@ -261,7 +266,9 @@ def create_app(
|
|||
|
||||
@app.get("/tenants/{tenant_id}/roles/live")
|
||||
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)
|
||||
|
||||
# -- Write API (grant/revoke/plan mutation) ---------------------------
|
||||
|
|
@ -270,7 +277,13 @@ def create_app(
|
|||
|
||||
@app.post("/tenants", status_code=201)
|
||||
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:
|
||||
tenant = Tenant.create(
|
||||
tenant_id=payload.tenant_id,
|
||||
|
|
@ -279,7 +292,8 @@ def create_app(
|
|||
contact_email=payload.contact_email,
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except TenantAlreadyExistsError as exc:
|
||||
|
|
@ -290,7 +304,9 @@ def create_app(
|
|||
|
||||
@app.post("/tenants/{tenant_id}/roles/grant", status_code=201)
|
||||
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:
|
||||
tenant = store.get_tenant(tenant_id)
|
||||
grant = create_role_grant(
|
||||
|
|
@ -303,7 +319,8 @@ def create_app(
|
|||
correlation_id=payload.correlation_id,
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||
except TenantRetiredError as exc:
|
||||
|
|
@ -314,9 +331,17 @@ def create_app(
|
|||
|
||||
@app.post("/tenants/{tenant_id}/roles/revoke")
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||
except GrantNotFoundError as exc:
|
||||
|
|
@ -327,11 +352,17 @@ def create_app(
|
|||
|
||||
@app.post("/tenants/{tenant_id}/plan")
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="tenant_not_found") from exc
|
||||
except TenantRetiredError as exc:
|
||||
|
|
@ -366,6 +397,7 @@ def create_app(
|
|||
event_type="tenant_updated",
|
||||
extra_fingerprint=changes,
|
||||
mutate=lambda tenant, at: tenant.with_metadata(changes, at=at),
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
@app.post("/tenants/{tenant_id}/retire")
|
||||
|
|
@ -390,6 +422,7 @@ def create_app(
|
|||
event_type="tenant_retired",
|
||||
extra_fingerprint={},
|
||||
mutate=lambda tenant, at: tenant.retire(at=at),
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
@app.post("/tenants/{tenant_id}/reactivate")
|
||||
|
|
@ -414,6 +447,7 @@ def create_app(
|
|||
event_type="tenant_reactivated",
|
||||
extra_fingerprint={},
|
||||
mutate=lambda tenant, at: tenant.reactivate(at=at),
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
# -- Reclassification (TEN-WP-0010) -----------------------------------
|
||||
|
|
@ -443,6 +477,7 @@ def create_app(
|
|||
event_type="tenant_grouping_changed",
|
||||
extra_fingerprint={"grouping": payload.grouping},
|
||||
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
# -- Guardrail API (TEN-WP-0006) --------------------------------------
|
||||
|
|
@ -451,7 +486,9 @@ def create_app(
|
|||
|
||||
@app.get("/tenants/{tenant_id}/guardrails")
|
||||
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:
|
||||
tenant = store.get_tenant(tenant_id)
|
||||
overrides = store.guardrail_overrides(tenant_id)
|
||||
|
|
@ -487,9 +524,7 @@ def create_app(
|
|||
try:
|
||||
value = load_limit(payload.limit.model_dump())
|
||||
except (InvalidLimitError, ValueError) as exc:
|
||||
raise LifecycleError(
|
||||
400, "invalid_limit", str(exc), payload.correlation_id
|
||||
) from exc
|
||||
raise LifecycleError(400, "invalid_limit", str(exc), payload.correlation_id) from exc
|
||||
return _guardrail_mutation(
|
||||
store=store,
|
||||
authorizer=authorizer,
|
||||
|
|
@ -502,6 +537,7 @@ def create_app(
|
|||
idempotency_key=idempotency_key,
|
||||
if_match=if_match,
|
||||
response=response,
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
|
||||
|
|
@ -525,6 +561,7 @@ def create_app(
|
|||
idempotency_key=idempotency_key,
|
||||
if_match=if_match,
|
||||
response=response,
|
||||
audit_core=app.state.audit_core,
|
||||
)
|
||||
|
||||
return app
|
||||
|
|
@ -553,6 +590,7 @@ def _guardrail_mutation(
|
|||
idempotency_key: str | None,
|
||||
if_match: str | None,
|
||||
response: Response,
|
||||
audit_core: AuditCoreClient | None = None,
|
||||
) -> dict:
|
||||
"""Shared spine for setting and clearing an override.
|
||||
|
||||
|
|
@ -566,7 +604,9 @@ def _guardrail_mutation(
|
|||
)
|
||||
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(
|
||||
json.dumps(
|
||||
|
|
@ -585,9 +625,9 @@ def _guardrail_mutation(
|
|||
# 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
|
||||
# second one for a mutation that happened once.
|
||||
change_id = hashlib.sha256(
|
||||
f"{tenant_id}:{limit_key}:{idempotency_key}".encode()
|
||||
).hexdigest()[:32]
|
||||
change_id = hashlib.sha256(f"{tenant_id}:{limit_key}:{idempotency_key}".encode()).hexdigest()[
|
||||
:32
|
||||
]
|
||||
|
||||
try:
|
||||
tenant, change, replayed = store.set_guardrail_override(
|
||||
|
|
@ -602,7 +642,9 @@ def _guardrail_mutation(
|
|||
idempotency_key=idempotency_key,
|
||||
request_fingerprint=fingerprint,
|
||||
at=datetime.now(UTC),
|
||||
authz=outcome.as_payload(),
|
||||
)
|
||||
_drain_outbox(store, audit_core)
|
||||
except UnknownLimitKeyError as exc:
|
||||
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
|
||||
except TenantNotFoundError as exc:
|
||||
|
|
@ -622,9 +664,7 @@ def _guardrail_mutation(
|
|||
correlation_id,
|
||||
) from exc
|
||||
except TenantRetiredError as exc:
|
||||
raise LifecycleError(
|
||||
409, "guardrail_loosening_denied", str(exc), correlation_id
|
||||
) from exc
|
||||
raise LifecycleError(409, "guardrail_loosening_denied", str(exc), correlation_id) from exc
|
||||
except (InvalidLimitError, ConflictingLimitError) as exc:
|
||||
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
|
||||
except StoreUnavailableError as exc:
|
||||
|
|
@ -704,6 +744,7 @@ def _lifecycle_mutation(
|
|||
event_type: str,
|
||||
extra_fingerprint: dict,
|
||||
mutate,
|
||||
audit_core: AuditCoreClient | None = None,
|
||||
) -> dict:
|
||||
"""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)
|
||||
|
||||
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(
|
||||
json.dumps(
|
||||
|
|
@ -738,10 +779,16 @@ def _lifecycle_mutation(
|
|||
expected_version=expected_version,
|
||||
mutate=lambda current: mutate(current, now),
|
||||
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,
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
_drain_outbox(store, audit_core)
|
||||
except TenantNotFoundError as exc:
|
||||
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
|
||||
except IdempotencyConflictError as exc:
|
||||
|
|
@ -787,6 +834,69 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer:
|
|||
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:
|
||||
try:
|
||||
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 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.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] = {
|
||||
"tenant.read": "tenant",
|
||||
"tenant.create": "tenant",
|
||||
|
|
@ -15,63 +14,88 @@ _RESOURCE_TYPES: dict[str, str] = {
|
|||
"tenant.role.read": "role-grant",
|
||||
"tenant.role.read.live": "role-grant",
|
||||
"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.retire": "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.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",
|
||||
}
|
||||
|
||||
|
||||
@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):
|
||||
def __init__(self, action: str, reason: str = "denied") -> None:
|
||||
super().__init__(f"{action}: {reason}")
|
||||
self.action = action
|
||||
self.reason = reason
|
||||
def __init__(self, outcome: AuthorizationOutcome) -> None:
|
||||
super().__init__(f"{outcome.action}: {outcome.reason}")
|
||||
self.action = outcome.action
|
||||
self.reason = outcome.reason
|
||||
self.outcome = outcome
|
||||
|
||||
|
||||
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 --
|
||||
flex-auth is meant to gate them. A real flex-auth integration is an
|
||||
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."""
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||
"""Return an allow record, or raise WriteAuthorizationDeniedError."""
|
||||
...
|
||||
|
||||
|
||||
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:
|
||||
raise WriteAuthorizationDeniedError(
|
||||
action, "no flex-auth integration configured (default-deny stub)"
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> AuthorizationOutcome:
|
||||
outcome = AuthorizationOutcome(
|
||||
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:
|
||||
"""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
|
||||
check resolves to deny -- that's the correct fail-closed behavior, not
|
||||
a bug in this client (see flex_auth.FlexAuthCheckClient).
|
||||
Does not cache verdicts. A previous allow cannot authorize a later
|
||||
request — every call is a new check.
|
||||
"""
|
||||
|
||||
def __init__(self, *, client: FlexAuthCheckClient) -> None:
|
||||
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")
|
||||
request = CheckRequest(
|
||||
request_id=new_request_id(),
|
||||
|
|
@ -82,5 +106,19 @@ class FlexAuthWriteAuthorizer:
|
|||
resource_id=tenant_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
if not self._client.is_allowed(request):
|
||||
raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check")
|
||||
result = self._client.check(request)
|
||||
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_url_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
|
||||
def from_env(cls) -> "Settings":
|
||||
return cls(
|
||||
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"),
|
||||
port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")),
|
||||
database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") 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,
|
||||
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
|
||||
# grouping would silently move the platform's own identity onto
|
||||
# the grouping ladder.
|
||||
raise ImmutableFieldError(
|
||||
"reserved tenants are ungrouped and cannot be reclassified"
|
||||
)
|
||||
raise ImmutableFieldError("reserved tenants are ungrouped and cannot be reclassified")
|
||||
if self.lifecycle is not TenantLifecycle.ACTIVE:
|
||||
raise InvalidLifecycleTransitionError(
|
||||
"grouping of a retired tenant cannot be changed; reactivate first"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json)
|
||||
# allows five effects; only "allow" authorizes anything.
|
||||
from tenant_engine.stance import FAIL_CLOSED
|
||||
|
||||
ALLOW_EFFECT = "allow"
|
||||
|
||||
|
||||
|
|
@ -45,15 +48,35 @@ class CheckRequest:
|
|||
"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:
|
||||
"""Client for flex-auth's POST /v1/check.
|
||||
|
||||
Fail-closed by construction: every non-"allow" effect, every non-2xx
|
||||
response, every malformed body, and every transport failure (timeout,
|
||||
connection error) resolves to `False` from `is_allowed()`. Nothing
|
||||
raises past this boundary -- callers (the WriteAuthorizer seam) get a
|
||||
plain deny, not an exception to handle inconsistently.
|
||||
Fail-closed by construction. Every non-allow effect, every non-2xx,
|
||||
every malformed body, and every transport failure resolves to a
|
||||
CheckResult with allowed=False. Nothing raises past this boundary.
|
||||
Verdicts are never cached: every call is a new POST.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -73,33 +96,85 @@ class FlexAuthCheckClient:
|
|||
transport=transport,
|
||||
)
|
||||
|
||||
def is_allowed(self, request: CheckRequest) -> bool:
|
||||
def check(self, request: CheckRequest) -> CheckResult:
|
||||
digest = request.digest()
|
||||
try:
|
||||
headers: dict[str, str] = {}
|
||||
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:
|
||||
token = token_file.read().strip()
|
||||
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}"
|
||||
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
|
||||
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:
|
||||
return False
|
||||
return CheckResult(
|
||||
allowed=False,
|
||||
source="stance",
|
||||
reason=f"access_engine_http_{response.status_code}",
|
||||
request_digest=digest,
|
||||
stance=FAIL_CLOSED,
|
||||
)
|
||||
|
||||
try:
|
||||
envelope = response.json()
|
||||
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):
|
||||
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:
|
||||
self._client.close()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ except ImportError: # pragma: no cover - exercised by the deployment guard
|
|||
ConnectionPool = None
|
||||
PoolTimeout = None
|
||||
|
||||
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
|
|
@ -35,9 +36,11 @@ from tenant_engine.guardrail import (
|
|||
load_limit,
|
||||
)
|
||||
from tenant_engine.store import (
|
||||
AuthorizationRecord,
|
||||
DomainEvent,
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
OutboxRow,
|
||||
StoreUnavailableError,
|
||||
TenantAlreadyExistsError,
|
||||
TenantNotFoundError,
|
||||
|
|
@ -118,7 +121,7 @@ class PostgresTenantStore:
|
|||
with self._pool.connection() as conn:
|
||||
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:
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
conn.execute(
|
||||
|
|
@ -144,7 +147,7 @@ class PostgresTenantStore:
|
|||
conn,
|
||||
"tenant_created",
|
||||
tenant.tenant_id,
|
||||
{"identifier": tenant.identifier, "grouping": tenant.grouping},
|
||||
{"identifier": tenant.identifier, "grouping": tenant.grouping, **(authz or {})},
|
||||
)
|
||||
except StoreUnavailableError as exc:
|
||||
if isinstance(exc.__cause__, UniqueViolation):
|
||||
|
|
@ -204,15 +207,13 @@ class PostgresTenantStore:
|
|||
updated.tenant_id,
|
||||
),
|
||||
)
|
||||
self._record_receipt(
|
||||
conn, updated, idempotency_key, request_fingerprint
|
||||
)
|
||||
self._record_receipt(conn, updated, idempotency_key, request_fingerprint)
|
||||
self._emit(
|
||||
conn, event_type, updated.tenant_id, {**evidence, "version": updated.version}
|
||||
)
|
||||
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)
|
||||
self._require_active(tenant, "grant a role")
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
|
|
@ -248,10 +249,13 @@ class PostgresTenantStore:
|
|||
"role": grant.role.value,
|
||||
"grant_reason": grant.grant_reason,
|
||||
"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)
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
row = conn.execute(
|
||||
|
|
@ -266,7 +270,7 @@ class PostgresTenantStore:
|
|||
conn,
|
||||
"role_revoked",
|
||||
tenant.tenant_id,
|
||||
{"grant_id": grant_id, "role": grant.role.value},
|
||||
{"grant_id": grant_id, "role": grant.role.value, **(authz or {})},
|
||||
)
|
||||
return grant
|
||||
|
||||
|
|
@ -279,7 +283,9 @@ class PostgresTenantStore:
|
|||
).fetchall()
|
||||
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)
|
||||
self._require_active(tenant, "assign a plan")
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
|
|
@ -290,17 +296,89 @@ class PostgresTenantStore:
|
|||
(tenant.tenant_id, assignment.plan_id, assignment.assigned_at),
|
||||
)
|
||||
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:
|
||||
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 [
|
||||
DomainEvent(row["event_type"], row["tenant_id"], row["at"], row["payload"])
|
||||
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]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
with self._pool.connection() as conn:
|
||||
|
|
@ -333,6 +411,7 @@ class PostgresTenantStore:
|
|||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
at: datetime,
|
||||
authz: dict[str, Any] | None = None,
|
||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||
DEFAULT_REGISTRY.get(limit_key)
|
||||
with self._pool.connection() as conn, conn.transaction():
|
||||
|
|
@ -428,6 +507,7 @@ class PostgresTenantStore:
|
|||
"cleared": value is None,
|
||||
"correlation_id": correlation_id,
|
||||
"version": updated.version,
|
||||
**(authz or {}),
|
||||
},
|
||||
)
|
||||
return updated, change, False
|
||||
|
|
@ -467,9 +547,24 @@ class PostgresTenantStore:
|
|||
|
||||
@staticmethod
|
||||
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(
|
||||
"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,
|
||||
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
|
||||
"retired_at": tenant.retired_at.isoformat() if tenant.retired_at else None,
|
||||
"reactivated_at": (
|
||||
tenant.reactivated_at.isoformat() if tenant.reactivated_at else None
|
||||
),
|
||||
"reactivated_at": (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"],
|
||||
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 typing import Any
|
||||
|
||||
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
|
|
@ -24,9 +25,11 @@ from tenant_engine.guardrail import (
|
|||
load_limit,
|
||||
)
|
||||
from tenant_engine.store import (
|
||||
AuthorizationRecord,
|
||||
DomainEvent,
|
||||
GrantNotFoundError,
|
||||
IdempotencyConflictError,
|
||||
OutboxRow,
|
||||
TenantAlreadyExistsError,
|
||||
TenantNotFoundError,
|
||||
VersionConflictError,
|
||||
|
|
@ -62,6 +65,19 @@ class SQLiteTenantStore:
|
|||
seq INTEGER PRIMARY KEY AUTOINCREMENT, event_type 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 (
|
||||
tenant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL, result TEXT NOT NULL,
|
||||
|
|
@ -110,7 +126,7 @@ class SQLiteTenantStore:
|
|||
with self._lock:
|
||||
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:
|
||||
try:
|
||||
self._db.execute(
|
||||
|
|
@ -118,16 +134,31 @@ class SQLiteTenantStore:
|
|||
contact_email, lifecycle, version, created_at, updated_at,
|
||||
retired_at, reactivated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(tenant.tenant_id, tenant.identifier, 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)),
|
||||
(
|
||||
tenant.tenant_id,
|
||||
tenant.identifier,
|
||||
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:
|
||||
raise TenantAlreadyExistsError(tenant.identifier) from exc
|
||||
self._emit("tenant_created", tenant.tenant_id, {
|
||||
"identifier": tenant.identifier, "grouping": tenant.grouping,
|
||||
})
|
||||
self._emit(
|
||||
"tenant_created",
|
||||
tenant.tenant_id,
|
||||
{
|
||||
"identifier": tenant.identifier,
|
||||
"grouping": tenant.grouping,
|
||||
**(authz or {}),
|
||||
},
|
||||
)
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||
# Reads take the same lock as writes. One sqlite3 connection is shared
|
||||
|
|
@ -189,15 +220,27 @@ class SQLiteTenantStore:
|
|||
contact_email = ?, lifecycle = ?, version = ?, updated_at = ?,
|
||||
retired_at = ?, reactivated_at = ?
|
||||
WHERE tenant_id = ?""",
|
||||
(updated.grouping, updated.display_name, updated.contact_email,
|
||||
updated.lifecycle.value, updated.version, _iso(updated.updated_at),
|
||||
_iso(updated.retired_at), _iso(updated.reactivated_at),
|
||||
updated.tenant_id),
|
||||
(
|
||||
updated.grouping,
|
||||
updated.display_name,
|
||||
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(
|
||||
"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})
|
||||
except BaseException:
|
||||
|
|
@ -251,6 +294,7 @@ class SQLiteTenantStore:
|
|||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
at: datetime,
|
||||
authz: dict[str, Any] | None = None,
|
||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||
tenant = self.get_tenant(tenant_id)
|
||||
DEFAULT_REGISTRY.get(limit_key)
|
||||
|
|
@ -274,8 +318,11 @@ class SQLiteTenantStore:
|
|||
replayed = _tenant(json.loads(receipt["result"]))
|
||||
self._db.rollback()
|
||||
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,
|
||||
)
|
||||
return replayed, prior, True
|
||||
|
|
@ -317,16 +364,29 @@ class SQLiteTenantStore:
|
|||
dumped = dump_limit(value)
|
||||
self._db.execute(
|
||||
"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(
|
||||
"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,
|
||||
json.dumps(dump_limit(change.current)) if change.current else None,
|
||||
change.changed_by, change.reason, change.correlation_id, _iso(at)),
|
||||
(
|
||||
change.change_id,
|
||||
change.tenant_id,
|
||||
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)
|
||||
|
|
@ -336,35 +396,65 @@ class SQLiteTenantStore:
|
|||
)
|
||||
self._db.execute(
|
||||
"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:
|
||||
self._db.rollback()
|
||||
raise
|
||||
self._db.commit()
|
||||
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)
|
||||
self._require_active(tenant, "grant a role")
|
||||
with self._lock, self._db:
|
||||
self._db.execute(
|
||||
"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.correlation_id, grant.revoked_at.isoformat() if grant.revoked_at else None),
|
||||
(
|
||||
grant.grant_id,
|
||||
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)
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
|
|
@ -375,10 +465,14 @@ class SQLiteTenantStore:
|
|||
raise GrantNotFoundError(grant_id)
|
||||
grant = self._grant(row).revoke(at=at)
|
||||
with self._lock, self._db:
|
||||
self._db.execute("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._db.execute(
|
||||
"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, **(authz or {})},
|
||||
)
|
||||
return grant
|
||||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||
|
|
@ -390,7 +484,9 @@ class SQLiteTenantStore:
|
|||
).fetchall()
|
||||
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)
|
||||
self._require_active(tenant, "assign a plan")
|
||||
with self._lock, self._db:
|
||||
|
|
@ -398,14 +494,94 @@ class SQLiteTenantStore:
|
|||
"INSERT OR REPLACE INTO plans VALUES (?, ?, ?)",
|
||||
(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:
|
||||
rows = self._db.execute("SELECT * FROM events ORDER BY seq").fetchall()
|
||||
return [DomainEvent(row["event_type"], row["tenant_id"],
|
||||
datetime.fromisoformat(row["at"]), json.loads(row["payload"]))
|
||||
for row in rows]
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM events WHERE tenant_id = ? ORDER BY seq", (tenant.tenant_id,)
|
||||
).fetchall()
|
||||
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
|
||||
def _require_active(tenant: Tenant, what: str) -> None:
|
||||
|
|
@ -413,22 +589,74 @@ class SQLiteTenantStore:
|
|||
raise TenantRetiredError(f"cannot {what} on a retired tenant")
|
||||
|
||||
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()
|
||||
self._db.execute("INSERT INTO events(event_type,tenant_id,at,payload) VALUES(?,?,?,?)",
|
||||
(event_type, tenant_id, now.isoformat(), json.dumps(payload)))
|
||||
self._db.execute(
|
||||
"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
|
||||
def _grant(row: sqlite3.Row) -> RoleGrant:
|
||||
return RoleGrant(row["grant_id"], row["tenant_id"], 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)
|
||||
return RoleGrant(
|
||||
row["grant_id"],
|
||||
row["tenant_id"],
|
||||
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:
|
||||
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:
|
||||
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 typing import Any, Protocol
|
||||
|
||||
from tenant_engine.audit_core import envelope_for, new_event_id
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
PlanAssignment,
|
||||
|
|
@ -64,6 +65,37 @@ class DomainEvent:
|
|||
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):
|
||||
"""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).
|
||||
"""
|
||||
|
||||
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 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 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(
|
||||
self,
|
||||
|
|
@ -202,12 +246,14 @@ class InMemoryTenantStore:
|
|||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||
self._plans: dict[str, PlanAssignment] = {}
|
||||
self._events: list[DomainEvent] = []
|
||||
self._authz: list[AuthorizationRecord] = []
|
||||
self._outbox: dict[str, OutboxRow] = {}
|
||||
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
|
||||
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
|
||||
self._overrides: dict[str, dict[str, LimitValue]] = {}
|
||||
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:
|
||||
raise TenantAlreadyExistsError(tenant.tenant_id)
|
||||
if tenant.identifier in self._by_identifier:
|
||||
|
|
@ -218,13 +264,13 @@ class InMemoryTenantStore:
|
|||
self._emit(
|
||||
"tenant_created",
|
||||
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:
|
||||
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)
|
||||
self._require_active(resolved, "grant a role")
|
||||
self._grants[resolved][grant.grant_id] = grant
|
||||
|
|
@ -236,10 +282,18 @@ class InMemoryTenantStore:
|
|||
"role": grant.role.value,
|
||||
"grant_reason": grant.grant_reason,
|
||||
"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)
|
||||
try:
|
||||
grant = self._grants[resolved][grant_id]
|
||||
|
|
@ -250,7 +304,7 @@ class InMemoryTenantStore:
|
|||
self._emit(
|
||||
"role_revoked",
|
||||
resolved,
|
||||
{"grant_id": grant_id, "role": revoked.role.value},
|
||||
{"grant_id": grant_id, "role": revoked.role.value, **(authz or {})},
|
||||
)
|
||||
return revoked
|
||||
|
||||
|
|
@ -260,14 +314,48 @@ class InMemoryTenantStore:
|
|||
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)
|
||||
self._require_active(resolved, "assign a plan")
|
||||
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]:
|
||||
return list(self._events)
|
||||
def events_for(self, tenant_id: str) -> list[DomainEvent]:
|
||||
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(
|
||||
self,
|
||||
|
|
@ -320,6 +408,7 @@ class InMemoryTenantStore:
|
|||
idempotency_key: str,
|
||||
request_fingerprint: str,
|
||||
at: datetime,
|
||||
authz: dict[str, Any] | None = None,
|
||||
) -> tuple[Tenant, GuardrailChange | None, bool]:
|
||||
resolved = self._resolve(tenant_id)
|
||||
|
||||
|
|
@ -342,9 +431,7 @@ class InMemoryTenantStore:
|
|||
raise VersionConflictError(expected=expected_version, actual=current.version)
|
||||
|
||||
overrides = self._overrides.setdefault(resolved, {})
|
||||
guard_guardrail_write(
|
||||
tenant=current, overrides=overrides, limit_key=limit_key, value=value
|
||||
)
|
||||
guard_guardrail_write(tenant=current, overrides=overrides, limit_key=limit_key, value=value)
|
||||
|
||||
previous = overrides.get(limit_key)
|
||||
change = GuardrailChange(
|
||||
|
|
@ -377,6 +464,7 @@ class InMemoryTenantStore:
|
|||
"cleared": value is None,
|
||||
"correlation_id": correlation_id,
|
||||
"version": updated.version,
|
||||
**(authz or {}),
|
||||
},
|
||||
)
|
||||
return updated, change, False
|
||||
|
|
@ -405,6 +493,19 @@ class InMemoryTenantStore:
|
|||
return resolved
|
||||
|
||||
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(
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
"""TRUNCATE guardrail_changes, guardrail_overrides,
|
||||
"""TRUNCATE audit_outbox, authz_records, guardrail_changes, guardrail_overrides,
|
||||
idempotency_receipts, events, plans, grants, tenants
|
||||
RESTART IDENTITY CASCADE"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from helpers import AllowAllAuthorizer, ScopedAuthorizer, deny
|
||||
|
||||
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
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
class _AllowAllAuthorizer(WriteAuthorizer):
|
||||
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
|
||||
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.
|
||||
"""
|
||||
class _TenantScopedAuthorizer:
|
||||
"""Permits guardrail work on exactly one tenant."""
|
||||
|
||||
def __init__(self, permitted_tenant: str) -> None:
|
||||
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":
|
||||
return
|
||||
return AllowAllAuthorizer().authorize(action=action, tenant_id=tenant_id, actor=actor)
|
||||
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):
|
||||
|
|
@ -57,7 +40,7 @@ class _BrokenWriteStore(InMemoryTenantStore):
|
|||
|
||||
def make_client(authorizer=None, store=None) -> TestClient:
|
||||
app = create_app(
|
||||
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
|
||||
store=store or InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer()
|
||||
)
|
||||
client = TestClient(app)
|
||||
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():
|
||||
# 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 put(client).status_code == 403
|
||||
|
||||
|
||||
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 put(client).status_code == 200
|
||||
|
||||
|
||||
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"})
|
||||
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
|
||||
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():
|
||||
client = make_client(_ScopedAuthorizer("tenant.create"))
|
||||
client = make_client(ScopedAuthorizer("tenant.create"))
|
||||
body = put(client).json()
|
||||
assert "tenant.db" not in str(body)
|
||||
assert body["error_code"] == "write_denied"
|
||||
|
|
|
|||
|
|
@ -2,32 +2,15 @@
|
|||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from helpers import AllowAllAuthorizer, ScopedAuthorizer
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
||||
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
|
||||
|
||||
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"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):
|
||||
def mutate_tenant(self, **kwargs):
|
||||
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
|
||||
|
|
@ -38,7 +21,7 @@ class _BrokenStore(InMemoryTenantStore):
|
|||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = create_app(store=InMemoryTenantStore(), authorizer=_AllowAllAuthorizer())
|
||||
app = create_app(store=InMemoryTenantStore(), authorizer=AllowAllAuthorizer())
|
||||
test_client = TestClient(app)
|
||||
test_client.post(
|
||||
"/tenants",
|
||||
|
|
@ -79,9 +62,7 @@ def test_get_tenant_returns_record_and_etag(client) -> None:
|
|||
|
||||
|
||||
def test_get_tenant_resolves_by_identifier(client) -> None:
|
||||
response = client.get(
|
||||
"/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"}
|
||||
)
|
||||
response = client.get("/tenants/tenant:friendly:binky", params={"actor": "tenant-engine"})
|
||||
assert response.status_code == 200
|
||||
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:
|
||||
app = create_app(
|
||||
store=InMemoryTenantStore(),
|
||||
authorizer=_ScopedAuthorizer("tenant.create", "tenant.update"),
|
||||
authorizer=ScopedAuthorizer("tenant.create", "tenant.update"),
|
||||
)
|
||||
client = TestClient(app)
|
||||
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:
|
||||
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"})
|
||||
write = client.patch(
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from helpers import AllowAllAuthorizer
|
||||
|
||||
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.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:
|
||||
return TestClient(create_app(store=store, authorizer=_AllowAll()))
|
||||
return TestClient(create_app(store=store, authorizer=AllowAllAuthorizer()))
|
||||
|
||||
|
||||
class _BrokenStore:
|
||||
|
|
@ -43,8 +38,11 @@ class _BrokenStore:
|
|||
def assign_plan(self, assignment):
|
||||
return self._delegate.assign_plan(assignment)
|
||||
|
||||
def events(self):
|
||||
return self._delegate.events()
|
||||
def events_for(self, tenant_id):
|
||||
return self._delegate.events_for(tenant_id)
|
||||
|
||||
def record_authorization(self, record):
|
||||
return None
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
client = _client(_seeded_store())
|
||||
response = client.get(
|
||||
"/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"}
|
||||
)
|
||||
response = client.get("/tenants/tenant:friendly:binky/roles", params={"actor": "key-cape"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"tenant_id": "tenant:friendly:binky", "roles": ["CUS"]}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
from fastapi.testclient import TestClient
|
||||
from helpers import AllowAllAuthorizer
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizer
|
||||
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:
|
||||
store = InMemoryTenantStore()
|
||||
authorizer = _AllowAllAuthorizer() if allow else None
|
||||
authorizer = AllowAllAuthorizer() if allow else None
|
||||
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:
|
||||
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(
|
||||
"/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:
|
||||
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(
|
||||
"/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 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(
|
||||
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:
|
||||
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(
|
||||
|
|
@ -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 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(
|
||||
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:
|
||||
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(
|
||||
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 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
|
||||
|
||||
|
|
@ -36,7 +39,10 @@ def test_allow_effect_authorizes() -> None:
|
|||
@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"])
|
||||
def test_non_allow_effects_deny(effect: str) -> None:
|
||||
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
|
||||
|
||||
|
|
@ -83,7 +89,10 @@ def test_request_body_matches_schema_shape() -> None:
|
|||
import json
|
||||
|
||||
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())
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ from datetime import UTC, datetime
|
|||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from helpers import AllowAllAuthorizer, ScopedAuthorizer
|
||||
|
||||
from tenant_engine.app import create_app
|
||||
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
|
||||
from tenant_engine.domain import (
|
||||
CapabilityRole,
|
||||
EmptyUpdateError,
|
||||
|
|
@ -148,22 +148,8 @@ def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
|
|||
# --- 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:
|
||||
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll())
|
||||
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or AllowAllAuthorizer())
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/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():
|
||||
client = make_client(identifier="tenant:trial:acme")
|
||||
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
|
||||
"limits"
|
||||
][KEY]["amount"] == 0
|
||||
assert (
|
||||
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
||||
"amount"
|
||||
]
|
||||
== 0
|
||||
)
|
||||
post_grouping(client, "medium")
|
||||
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
|
||||
"limits"
|
||||
][KEY]["amount"] == 100_000
|
||||
assert (
|
||||
client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()["limits"][KEY][
|
||||
"amount"
|
||||
]
|
||||
== 100_000
|
||||
)
|
||||
|
||||
|
||||
def test_reclassification_is_authorized_separately_from_a_rename():
|
||||
# policy can permit a display-name edit without permitting a move that
|
||||
# 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 client.patch(
|
||||
"/tenants/t-1",
|
||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||
headers=HEADERS,
|
||||
).status_code == 200
|
||||
assert (
|
||||
client.patch(
|
||||
"/tenants/t-1",
|
||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||
headers=HEADERS,
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
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 client.patch(
|
||||
"/tenants/t-1",
|
||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
).status_code == 403
|
||||
assert (
|
||||
client.patch(
|
||||
"/tenants/t-1",
|
||||
json={"metadata": {"display_name": "Acme"}, **BODY},
|
||||
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
post_grouping(client)
|
||||
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 events[0].payload["actor"] == "ops"
|
||||
assert events[0].payload["reason"] == "grew past the band"
|
||||
|
|
|
|||
|
|
@ -73,9 +73,12 @@ def test_reduced_to_floor_only_ever_reduces():
|
|||
floor = eur(0)
|
||||
assert eur(500).reduced_to_floor(floor) == floor
|
||||
assert eur(0).reduced_to_floor(eur(500)) == eur(0)
|
||||
assert LimitValue(
|
||||
kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED
|
||||
).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3
|
||||
assert (
|
||||
LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED)
|
||||
.reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3))
|
||||
.amount
|
||||
== 3
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
assert grouping_only.provenance is Provenance.GROUPING
|
||||
|
||||
with_plan = resolve_limit(
|
||||
"spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)}
|
||||
)
|
||||
with_plan = resolve_limit("spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)})
|
||||
assert with_plan.provenance is Provenance.PLAN
|
||||
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):
|
||||
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 events[0].payload["limit_key"] == KEY
|
||||
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)
|
||||
# spend.monthly is the only registered key, so use it to prove the
|
||||
# sentinel serialises; the kind check lives in the domain tests
|
||||
set_override(store, tenant, LimitValue(
|
||||
kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"
|
||||
))
|
||||
set_override(
|
||||
store,
|
||||
tenant,
|
||||
LimitValue(kind=LimitKind.SPEND, amount=UNLIMITED, currency="EUR", period="P1M"),
|
||||
)
|
||||
stored = store.guardrail_overrides(tenant.tenant_id)[KEY]
|
||||
assert stored.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):
|
||||
set_override(store, tenant, eur(9_000))
|
||||
with pytest.raises(VersionConflictError):
|
||||
set_override(store, tenant, eur(1_000), version=1, change_id="c-2",
|
||||
idempotency_key="idem-2")
|
||||
set_override(
|
||||
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):
|
||||
|
|
@ -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):
|
||||
set_override(store, tenant, eur(9_000))
|
||||
with pytest.raises(VersionConflictError):
|
||||
set_override(store, tenant, eur(1), version=99, change_id="c-2",
|
||||
idempotency_key="idem-2")
|
||||
set_override(store, tenant, eur(1), version=99, change_id="c-2", idempotency_key="idem-2")
|
||||
assert store.get_tenant(tenant.tenant_id).version == 2
|
||||
assert len(store.guardrail_changes(tenant.tenant_id)) == 1
|
||||
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
|
||||
with pytest.raises(TenantRetiredError):
|
||||
set_override(store, tenant, None, version=3, change_id="c-3",
|
||||
idempotency_key="idem-3")
|
||||
set_override(store, tenant, None, version=3, change_id="c-3", idempotency_key="idem-3")
|
||||
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
|
||||
# so reactivation does not have to reconstruct anything.
|
||||
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:
|
||||
_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["reason"] == "rename"
|
||||
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()
|
||||
tenant = Tenant.create(tenant_id=f"t-{name}", identifier=f"tenant:{grouping}:{name}")
|
||||
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:
|
||||
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()
|
||||
assert any(event.event_type == "plan_assigned" and event.payload["plan_id"] == "plan-x" for event in 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
|
||||
)
|
||||
|
||||
|
||||
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.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"]
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -482,6 +482,7 @@ dependencies = [
|
|||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
postgres = [
|
||||
|
|
@ -496,6 +497,7 @@ requires-dist = [
|
|||
{ name = "psycopg", extras = ["binary"], 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 = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6,<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"
|
||||
domain: infotech
|
||||
repo: tenant-engine
|
||||
status: ready
|
||||
status: finished
|
||||
owner: grok
|
||||
topic_slug: netkingdom
|
||||
created: "2026-08-29"
|
||||
|
|
@ -58,7 +58,7 @@ code.
|
|||
|
||||
```task
|
||||
id: TEN-WP-0011-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f46944d8-e638-5f64-a4ad-a713102dfa72"
|
||||
```
|
||||
|
|
@ -81,7 +81,7 @@ in `src/`.
|
|||
|
||||
```task
|
||||
id: TEN-WP-0011-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
id: TEN-WP-0011-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7815548c-36aa-518c-812e-8562be3a5111"
|
||||
```
|
||||
|
|
@ -145,7 +145,7 @@ evidence, not a comment.
|
|||
|
||||
```task
|
||||
id: TEN-WP-0011-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
promoted and completed.
|
||||
|
||||
**Done 2026-08-29:** local outbox is in the mutation transaction; drain
|
||||
POSTs `/v1/events` and never fails the mutation; trade declared in
|
||||
`docs/evidence-emission.md`; no pre-cutover backfill. Production landing
|
||||
waits on sender registration, requested as `AUDIT-IN-0002`.
|
||||
|
||||
## T05 — Remove or authorize the unfiltered event-read interface
|
||||
|
||||
```task
|
||||
id: TEN-WP-0011-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
this repo's store protocol; tests say so; `TEN-IN-0002` can close.
|
||||
|
||||
**Done 2026-08-29:** `events()` removed from the production protocol;
|
||||
`events_for(tenant_id)` is the only read. Cross-tenant negative test in
|
||||
`tests/test_events_scoped.py`. Reading handed to risk-nexus.
|
||||
|
||||
## T06 — Request the boundary-contract amendment
|
||||
|
||||
```task
|
||||
id: TEN-WP-0011-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
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
|
||||
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