Implement SECRETS-WP-0008 unblocked layer-model obligations
Load pep-stance.yaml as the live unreachable-engine gate and record named stance fields on privileged evidence. Classify evidence, queue load-bearing records in a local outbox, and add heartbeat/drain commands that never sit on a mutation path. Publish proposed SSH-CA and secret-use evidence contracts without adding an OpenBao SSH-CA write. T02 (access-engine decision records) and T06 (no standing credential) stay wait on external endpoints. Assistant: grok Assistant-Session: 01a04cea-cb33-7c63-bad7-c1b0f9f0076b
This commit is contained in:
parent
57f6c4fa65
commit
3cd9955ac9
16 changed files with 1041 additions and 77 deletions
35
SCOPE.md
35
SCOPE.md
|
|
@ -193,6 +193,7 @@ secrets-engine route
|
||||||
secrets-engine revoke [--dry-run]
|
secrets-engine revoke [--dry-run]
|
||||||
secrets-engine lifecycle suspend|deactivate|destroy
|
secrets-engine lifecycle suspend|deactivate|destroy
|
||||||
secrets-engine audit <catalog-id> [--json]
|
secrets-engine audit <catalog-id> [--json]
|
||||||
|
secrets-engine evidence heartbeat|drain|classify
|
||||||
```
|
```
|
||||||
|
|
||||||
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
|
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
|
||||||
|
|
@ -233,16 +234,17 @@ verification, `approle-login` is auth-capability handoff metadata, and
|
||||||
- Management or health verification of ESO, Kubernetes Secrets, deployments,
|
- Management or health verification of ESO, Kubernetes Secrets, deployments,
|
||||||
provider accounts, SSH issuance, tunnels, or remote transport.
|
provider accounts, SSH issuance, tunnels, or remote transport.
|
||||||
- Any backend other than the local `bao`/`vault` CLI speaking to OpenBao.
|
- Any backend other than the local `bao`/`vault` CLI speaking to OpenBao.
|
||||||
- An SSH-CA signing engine API. ops-warden still signs through its declared
|
- An SSH-CA signing engine API. The contract is
|
||||||
OpenBao gap; this repository has accepted that surface as proposed only.
|
`docs/ssh-ca-signing-contract.md`; ops-warden still signs through its
|
||||||
- A secret-use evidence engine API for kings-guard. `route` and `audit` are
|
declared OpenBao gap.
|
||||||
operator summaries over local JSONL, not an observation surface.
|
- A secret-use evidence engine API for kings-guard. The contract is
|
||||||
- Emission to `audit-core`. Evidence today is local JSONL plus best-effort
|
`docs/secret-use-evidence-contract.md`. `route` and `audit` are operator
|
||||||
State Hub progress notes, classified attributive, completeness not claimed.
|
summaries over local JSONL, not that observation surface.
|
||||||
- Named stance-application records (stage, failure mode, decision id present
|
- Emission to `audit-core`. Load-bearing records are queued locally; drain
|
||||||
only where rendered). Fail-closed production currently surfaces as a
|
requires a sender binding that does not exist yet. Completeness is not
|
||||||
`DecisionError` on the privileged-evidence path.
|
claimed.
|
||||||
- Security-zone membership as a request claim. PEP scope is catalog stage.
|
- Security-zone membership as a request claim. PEP scope is catalog stage.
|
||||||
|
- Drain of the load-bearing outbox into a live `audit-core` sender binding.
|
||||||
|
|
||||||
## System Boundary
|
## System Boundary
|
||||||
|
|
||||||
|
|
@ -299,12 +301,12 @@ Working companion: `net-kingdom/SECURITY-COMPANION.md`.
|
||||||
| Obligation | Current | Intended |
|
| Obligation | Current | Intended |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Layer declaration | `INTENT.md` frontmatter + `layer.yaml` | Keep in this repository's own voice |
|
| Layer declaration | `INTENT.md` frontmatter + `layer.yaml` | Keep in this repository's own voice |
|
||||||
| One decision point | Consumer validator for ActionAuthorization; production live fail-closed | Consume an access-engine decision record before every protected side effect |
|
| One decision point | Consumer validator for ActionAuthorization; production live fail-closed from `pep-stance.yaml` | Consume an access-engine decision record before every protected side effect |
|
||||||
| PEP stance | `pep-stance.yaml`; prod fail-closed, build/test fail-open relative to access-engine | Published map equals shipped behaviour; stance application recorded by name |
|
| PEP stance | Runtime loads `pep-stance.yaml`; named stance fields on privileged evidence | Unchanged map; T02 replaces fail-open residue with a decision record |
|
||||||
| Evidence bound | Attributive local JSONL + best-effort State Hub | Load-bearing vs attributive classified; load-bearing to `audit-core` with cadence |
|
| Evidence bound | `evidence-classification.yaml`; load-bearing local outbox; heartbeat command | Drain to `audit-core` once that sender is admitted |
|
||||||
| SSH-CA surface | Proposed; not shipped | Engine API for ops-warden's Staff PEP; lane stewardship stays with ops-warden |
|
| SSH-CA surface | Contract at `docs/ssh-ca-signing-contract.md`; not shipped | Engine API after ops-warden assent |
|
||||||
| Secret-use evidence | `route` / `audit` over local JSONL | Engine surface of lease/revocation/mount/rotation metadata |
|
| Secret-use evidence | Contract at `docs/secret-use-evidence-contract.md`; not shipped | Engine surface after kings-guard assent |
|
||||||
| Agent credential | Bootstrap token file still accepted; KeyCape scaffold unwired | Per-task, time-bounded service identity; no standing engine credential |
|
| Agent credential | Bootstrap token file still accepted; KeyCape scaffold unwired to OpenBao | Per-task, time-bounded service identity; no standing engine credential |
|
||||||
|
|
||||||
## Where Current Work Lives
|
## Where Current Work Lives
|
||||||
|
|
||||||
|
|
@ -312,7 +314,8 @@ Working companion: `net-kingdom/SECURITY-COMPANION.md`.
|
||||||
- `docs/hardening-backlog.md` tracks exit from bootstrap mode.
|
- `docs/hardening-backlog.md` tracks exit from bootstrap mode.
|
||||||
- `history/` contains dated capability and intent assessments.
|
- `history/` contains dated capability and intent assessments.
|
||||||
- `INTENT.md` remains the stable aspirational direction.
|
- `INTENT.md` remains the stable aspirational direction.
|
||||||
- `layer.yaml` and `pep-stance.yaml` are the layer-model declaration surface.
|
- `layer.yaml`, `pep-stance.yaml`, and `evidence-classification.yaml` are the
|
||||||
|
layer-model declaration surface.
|
||||||
|
|
||||||
## Provided Capabilities
|
## Provided Capabilities
|
||||||
|
|
||||||
|
|
|
||||||
78
docs/secret-use-evidence-contract.md
Normal file
78
docs/secret-use-evidence-contract.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Secret-use evidence engine contract (proposed)
|
||||||
|
|
||||||
|
Status: **proposed**. `layer.yaml` owner_status remains `proposed` until this
|
||||||
|
surface ships. kings-guard must assent or contest before it is treated as an
|
||||||
|
observation input.
|
||||||
|
|
||||||
|
Standard: NetKingdom Security Layer Model v0.7 §9.6. Companion:
|
||||||
|
`net-kingdom/SECURITY-COMPANION.md` §6.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A read-only Lifecycle engine surface of **non-secret** lease, revocation,
|
||||||
|
mount, rotation, and delivery-session metadata, so kings-guard can evaluate
|
||||||
|
secret-abuse posture without a vault client.
|
||||||
|
|
||||||
|
`secrets-engine route` and `secrets-engine audit` are operator summaries over
|
||||||
|
local JSONL. They are not this surface.
|
||||||
|
|
||||||
|
## Bound (normative)
|
||||||
|
|
||||||
|
The archive proves the records it holds were not altered or truncated after
|
||||||
|
arrival. It does **not** prove a record was sent. Absence of a record is **not**
|
||||||
|
evidence of non-occurrence. Completeness is not claimed.
|
||||||
|
|
||||||
|
This surface cannot close adversarial omission at a compromised source. Cadence
|
||||||
|
for load-bearing classes is a heartbeat (`secrets-engine evidence heartbeat`),
|
||||||
|
not rate monitoring.
|
||||||
|
|
||||||
|
## Fields (non-secret)
|
||||||
|
|
||||||
|
| Field | Meaning | Freshness |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `as_of` | UTC timestamp of the snapshot | Snapshot time |
|
||||||
|
| `catalog_id` | Lane id | Catalog load |
|
||||||
|
| `stage` | `build` \| `test` \| `prod` | Catalog load |
|
||||||
|
| `kind` | `kv` \| `auth-capability` | Catalog load |
|
||||||
|
| `mount` | OpenBao mount name | Catalog load |
|
||||||
|
| `path` | KV path or allowlisted auth path, never a value | Catalog load |
|
||||||
|
| `field_names` | Declared field names, never values | Catalog load |
|
||||||
|
| `ready` | All declared fields present (boolean) | Last route/verify |
|
||||||
|
| `session_handle` | Accessor fingerprint only | Last exec/verify |
|
||||||
|
| `revocation_attempted` | Boolean | Last session |
|
||||||
|
| `revocation_succeeded` | Boolean | Last session |
|
||||||
|
| `lifecycle_operation` | `suspend` \| `deactivate` \| `destroy` \| `revoke` if one ran | Last lifecycle |
|
||||||
|
| `decision_id` | Present only where a decision was rendered | Last privileged action |
|
||||||
|
| `stance_stage` / `stance_failure_mode` | Named PEP residue | Last privileged action |
|
||||||
|
| `evidence_kind` | `load-bearing` \| `attributive` \| `heartbeat` | Per record |
|
||||||
|
|
||||||
|
Forbidden in this surface: secret values, tokens, passwords, AppRole
|
||||||
|
`secret_id`, raw accessors, certificate private keys, subprocess argv, and
|
||||||
|
exception prose.
|
||||||
|
|
||||||
|
## Freshness
|
||||||
|
|
||||||
|
Every row carries `as_of`. There is no cached authorization verdict. A consumer
|
||||||
|
must not treat a stale snapshot as a standing allow. Recommended maximum age
|
||||||
|
for posture evaluation is the heartbeat interval declared in
|
||||||
|
`evidence-classification.yaml` (`1d`) until a tighter contract is assented.
|
||||||
|
|
||||||
|
## Destination
|
||||||
|
|
||||||
|
Intended owner of custody is `audit-core`. Today the engine queues load-bearing
|
||||||
|
records in `evidence_dir/outbox/` and writes attributive records to local
|
||||||
|
JSONL. Drain to `audit-core` is best-effort and is never on the mutation path.
|
||||||
|
|
||||||
|
kings-guard reads this engine surface, not OpenBao.
|
||||||
|
|
||||||
|
## Non-responsibilities
|
||||||
|
|
||||||
|
This surface does not decide whether an action is permitted, does not expose an
|
||||||
|
approval-validity query, does not prove omission at source, and does not
|
||||||
|
observe production events kings-guard has not received. Companion §10 still
|
||||||
|
holds: nothing is observed in production until kings-guard reports otherwise.
|
||||||
|
|
||||||
|
## Assent
|
||||||
|
|
||||||
|
A coordination message to kings-guard records this contract. Assent or contest
|
||||||
|
belongs in kings-guard's own voice.
|
||||||
90
docs/ssh-ca-signing-contract.md
Normal file
90
docs/ssh-ca-signing-contract.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# SSH-CA signing engine contract (proposed)
|
||||||
|
|
||||||
|
Status: **proposed**. `layer.yaml` owner_status remains `proposed` until this
|
||||||
|
surface ships. ops-warden must assent or contest before any replacement client
|
||||||
|
is written.
|
||||||
|
|
||||||
|
Standard: NetKingdom Security Layer Model v0.7 §3.3 (Lifecycle), §6.4 (PEP).
|
||||||
|
Companion: `net-kingdom/SECURITY-COMPANION.md`.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A deterministic Lifecycle engine API that asks OpenBao to sign a short-lived
|
||||||
|
SSH user certificate **after** an `access-engine` decision record naming the
|
||||||
|
request. secrets-engine presents no additional authority of its own beyond the
|
||||||
|
decision-bound role. The action reconstructs as the caller's.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
- Operational ownership of the SSH access lane. `warden sign`, `cert_command`,
|
||||||
|
and host-access stewardship stay with ops-warden (Staff PEP).
|
||||||
|
- A break-glass AppRole. The cataloged standalone `warden-sign` AppRole stays
|
||||||
|
parked.
|
||||||
|
- An authorization decision, an approval store, or a second PDP.
|
||||||
|
- A writer of private keys. The caller supplies the public key; the engine
|
||||||
|
returns only the signed certificate material through a scoped delivery path.
|
||||||
|
|
||||||
|
## Caller
|
||||||
|
|
||||||
|
ops-warden, after it holds an `access-engine` decision for the sign request, or
|
||||||
|
after it has recorded the application of its own published unreachable-engine
|
||||||
|
stance in place of a decision (`pep-stance.yaml` in ops-warden).
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `actor_type` | `adm` \| `agt` \| `atm` |
|
||||||
|
| `actor_name` | Attributable principal the certificate is issued for |
|
||||||
|
| `identity` | Certificate key id / identity string |
|
||||||
|
| `principals` | Exact principal list; no wildcards |
|
||||||
|
| `public_key` | Caller-supplied public key (not a private key) |
|
||||||
|
| `ttl` | Requested lifetime; engine enforces the actor-type maximum |
|
||||||
|
| `decision_id` | Access-engine decision record id, present only where a decision was rendered |
|
||||||
|
| `request_digest` | Canonical digest of subject, action, resource, context |
|
||||||
|
| `stance` | Named unreachable-engine residue if no decision was rendered |
|
||||||
|
|
||||||
|
Replay is permitted iff the canonical request digest matches and the decision
|
||||||
|
lifetime holds. A different resource is a different request.
|
||||||
|
|
||||||
|
## Response (non-secret)
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `signed` | Whether OpenBao signed |
|
||||||
|
| `valid_before` | Certificate expiry |
|
||||||
|
| `role` | OpenBao SSH role used (`agt-role` / `adm-role` / `atm-role`) |
|
||||||
|
| `decision_id` | Echoed when present |
|
||||||
|
| `evidence_id` | Local non-secret evidence record id |
|
||||||
|
|
||||||
|
Certificate private material never appears in this response, in evidence, or in
|
||||||
|
argv. Delivery of the signed certificate uses a scoped file or exec path with
|
||||||
|
mode 0600, outside Git, same rules as existing handoff.
|
||||||
|
|
||||||
|
## Lifetime
|
||||||
|
|
||||||
|
Every allow has an explicit end: the certificate TTL, bounded by actor-type
|
||||||
|
maxima already documented on the `warden-sign` lane. No standing grant.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Attributive unless a future control branches on the presence of a signing
|
||||||
|
record — today none does. Completeness is not claimed. Records may include
|
||||||
|
actor type, role, `valid_before`, decision id, and stance fields. They must
|
||||||
|
never include a public-key blob if it could identify a live private key
|
||||||
|
handle, a token, or certificate private material.
|
||||||
|
|
||||||
|
Destination today: local JSONL. Intended destination: `audit-core`.
|
||||||
|
|
||||||
|
## Non-responsibilities
|
||||||
|
|
||||||
|
secrets-engine does not choose principals, mint SSH keys, open tunnels, log
|
||||||
|
into hosts, or override ops-warden's zone stance map. If `access-engine` is
|
||||||
|
unreachable, this engine applies **its own** published catalog-stage stance
|
||||||
|
(`pep-stance.yaml` in this repository) and does not consult ops-warden's map.
|
||||||
|
|
||||||
|
## Assent
|
||||||
|
|
||||||
|
A coordination message to ops-warden records this contract. Assent or contest
|
||||||
|
belongs in ops-warden's own voice. Until then no OpenBao SSH-CA write is added
|
||||||
|
to the secrets-engine CLI.
|
||||||
62
evidence-classification.yaml
Normal file
62
evidence-classification.yaml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# secrets-engine — load-bearing vs attributive evidence (§9.6)
|
||||||
|
#
|
||||||
|
# Machine-readable classification next to the layer declaration.
|
||||||
|
# Completeness is not claimed. No control may branch on presence or
|
||||||
|
# absence of a local evidence record.
|
||||||
|
|
||||||
|
schema_version: "0.1"
|
||||||
|
framework: netkingdom-security-layer-model
|
||||||
|
standard_version: "0.7"
|
||||||
|
repository: secrets-engine
|
||||||
|
bound: "§9.6"
|
||||||
|
completeness_claimed: false
|
||||||
|
no_control_branches_on_presence: true
|
||||||
|
intended_destination: audit-core
|
||||||
|
destination_today: "local JSONL; load-bearing also queued in evidence_dir/outbox/"
|
||||||
|
sound_claim: >-
|
||||||
|
The archive proves the records it holds were not altered or truncated
|
||||||
|
after arrival. It does not prove a record was sent, and absence is not
|
||||||
|
evidence of non-occurrence.
|
||||||
|
|
||||||
|
cadence:
|
||||||
|
load_bearing_form: heartbeat
|
||||||
|
interval: 1d
|
||||||
|
action: evidence-heartbeat
|
||||||
|
claim: nothing-to-report
|
||||||
|
command: "secrets-engine evidence heartbeat"
|
||||||
|
|
||||||
|
# First match wins.
|
||||||
|
rules:
|
||||||
|
- id: heartbeat
|
||||||
|
kind: heartbeat
|
||||||
|
actions: [evidence-heartbeat]
|
||||||
|
stages: [build, test, prod, unknown]
|
||||||
|
emission: local-outbox
|
||||||
|
note: >-
|
||||||
|
Positive claim that can itself go missing. Queue depth is metadata,
|
||||||
|
not a permission.
|
||||||
|
|
||||||
|
- id: destroy
|
||||||
|
kind: load-bearing
|
||||||
|
actions: [lifecycle-destroy]
|
||||||
|
stages: [build, test, prod, unknown]
|
||||||
|
emission: local-outbox
|
||||||
|
note: Live destroy is currently disabled; refusals are still classified here.
|
||||||
|
|
||||||
|
- id: production-control-mutation
|
||||||
|
kind: load-bearing
|
||||||
|
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision]
|
||||||
|
stages: [prod]
|
||||||
|
emission: local-outbox
|
||||||
|
note: >-
|
||||||
|
Production provision, revoke, and suspend/deactivate once enabled.
|
||||||
|
Production live remains fail-closed until an access-engine decision
|
||||||
|
record is served.
|
||||||
|
|
||||||
|
- id: default-attributive
|
||||||
|
kind: attributive
|
||||||
|
actions: ["*"]
|
||||||
|
stages: [build, test, prod, unknown]
|
||||||
|
emission: local-jsonl
|
||||||
|
trade: >-
|
||||||
|
State Hub progress posts are non-blocking. Completeness is not claimed.
|
||||||
33
layer.yaml
33
layer.yaml
|
|
@ -68,10 +68,13 @@ proposed_capabilities:
|
||||||
(ops-warden) does not hold a direct VaultCA write.
|
(ops-warden) does not hold a direct VaultCA write.
|
||||||
intended_owner: secrets-engine
|
intended_owner: secrets-engine
|
||||||
owner_status: proposed
|
owner_status: proposed
|
||||||
|
contract: docs/ssh-ca-signing-contract.md
|
||||||
blocked_on: >-
|
blocked_on: >-
|
||||||
No engine API exists yet. ops-warden continues to sign through its
|
Contract published; ops-warden has not assented. No OpenBao SSH-CA
|
||||||
declared §5.3 gap. This repository accepts the engine surface and
|
write is added to this CLI until that assent. ops-warden continues
|
||||||
declines operational ownership of the SSH access lane.
|
to sign through its declared §5.3 gap. This repository accepts the
|
||||||
|
engine surface and declines operational ownership of the SSH access
|
||||||
|
lane.
|
||||||
review: "2026-11-28"
|
review: "2026-11-28"
|
||||||
consequence: >-
|
consequence: >-
|
||||||
ops-warden's ssh-ca-signing-write gap remains the production path.
|
ops-warden's ssh-ca-signing-write gap remains the production path.
|
||||||
|
|
@ -83,9 +86,11 @@ proposed_capabilities:
|
||||||
exposed as an engine surface for kings-guard secret-abuse posture.
|
exposed as an engine surface for kings-guard secret-abuse posture.
|
||||||
intended_owner: secrets-engine
|
intended_owner: secrets-engine
|
||||||
owner_status: proposed
|
owner_status: proposed
|
||||||
|
contract: docs/secret-use-evidence-contract.md
|
||||||
blocked_on: >-
|
blocked_on: >-
|
||||||
Local JSONL plus best-effort State Hub notes exist; they are not an
|
Contract published; kings-guard has not assented. Local JSONL, the
|
||||||
engine API and they are not audit-core. Completeness is not claimed.
|
load-bearing outbox, and `route`/`audit` are not this observation
|
||||||
|
API. Completeness is not claimed.
|
||||||
review: "2026-11-28"
|
review: "2026-11-28"
|
||||||
consequence: >-
|
consequence: >-
|
||||||
kings-guard secret-abuse posture stays fixture-driven.
|
kings-guard secret-abuse posture stays fixture-driven.
|
||||||
|
|
@ -130,15 +135,29 @@ non_tooling_clients:
|
||||||
preflight only and is deliberately not a write into OpenBao. Wiring it
|
preflight only and is deliberately not a write into OpenBao. Wiring it
|
||||||
as OpenBao JWT login remains blocked on the platform-owned role.
|
as OpenBao JWT login remains blocked on the platform-owned role.
|
||||||
|
|
||||||
|
- id: audit-core-outbox-drain
|
||||||
|
target: audit-core
|
||||||
|
layer: engine
|
||||||
|
modules:
|
||||||
|
- src/secrets_engine/evidence.py
|
||||||
|
operation: "Optional POST /v1/events from secrets-engine evidence drain"
|
||||||
|
write: true
|
||||||
|
note: >-
|
||||||
|
Engine-to-engine emit. Never on the mutation path. Empty URL skips
|
||||||
|
delivery and leaves the local outbox in place. Sender binding in
|
||||||
|
audit-core is not yet admitted.
|
||||||
|
|
||||||
# §9.6 — what this repository may claim about evidence it writes today.
|
# §9.6 — what this repository may claim about evidence it writes today.
|
||||||
evidence_bound:
|
evidence_bound:
|
||||||
kind: attributive
|
classification: evidence-classification.yaml
|
||||||
proves:
|
proves:
|
||||||
- local JSONL records that were written were not rewritten by this engine
|
- local JSONL records that were written were not rewritten by this engine
|
||||||
|
- load-bearing records queued in evidence_dir/outbox/ were written
|
||||||
does_not_prove:
|
does_not_prove:
|
||||||
- that a record was ever sent to audit-core
|
- that a record was ever sent to audit-core
|
||||||
- absence of a record as evidence of non-occurrence
|
- absence of a record as evidence of non-occurrence
|
||||||
- completeness of the trail
|
- completeness of the trail
|
||||||
destination_today: "local JSONL plus best-effort State Hub progress notes"
|
destination_today: "local JSONL; load-bearing also queued locally; attributive State Hub notes"
|
||||||
intended_destination: audit-core
|
intended_destination: audit-core
|
||||||
completeness_claimed: false
|
completeness_claimed: false
|
||||||
|
no_control_branches_on_presence: true
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,9 @@
|
||||||
# Catalog stage is the equivalent scope until security-zone membership arrives
|
# Catalog stage is the equivalent scope until security-zone membership arrives
|
||||||
# as a claim on the decision.
|
# as a claim on the decision.
|
||||||
#
|
#
|
||||||
# The property that makes this worth reading: tests/test_layer_conformance.py
|
# Runtime reads this file. tests/test_layer_conformance.py also pins
|
||||||
# asserts this map against the shipped production fail-closed gate in
|
# secrets_engine.pep_stance.SHIPPED_STANCE to this map. A published map free
|
||||||
# secrets_engine.cli._require_lane_approval. A published map free to drift
|
# to drift from that pin is worse than none.
|
||||||
# from the code is worse than none.
|
|
||||||
|
|
||||||
schema_version: "0.1"
|
schema_version: "0.1"
|
||||||
framework: netkingdom-security-layer-model
|
framework: netkingdom-security-layer-model
|
||||||
|
|
@ -58,20 +57,17 @@ demo_exception:
|
||||||
|
|
||||||
on_apply:
|
on_apply:
|
||||||
recorded_fields:
|
recorded_fields:
|
||||||
- stage
|
- stance_stage
|
||||||
- action
|
- stance_failure_mode
|
||||||
|
- stance_demo_exception
|
||||||
|
- stance_decision_id # present only where a decision was resolved
|
||||||
- approval_status # rejected when the stance refuses
|
- approval_status # rejected when the stance refuses
|
||||||
- error_type # DecisionError when fail_closed fires
|
- error_type # DecisionError when fail_closed fires
|
||||||
- decision_id # present only where a decision was resolved
|
|
||||||
written_to:
|
written_to:
|
||||||
- "local JSONL (.evidence/evidence-YYYY-MM-DD.jsonl)"
|
- "local JSONL (.evidence/evidence-YYYY-MM-DD.jsonl)"
|
||||||
- "best-effort State Hub progress note"
|
- "evidence_dir/outbox/ for load-bearing classes"
|
||||||
|
- "best-effort State Hub progress note (attributive only)"
|
||||||
never_recorded: "any secret material, token, password, or raw value"
|
never_recorded: "any secret material, token, password, or raw value"
|
||||||
gap: >-
|
|
||||||
Stance application is currently recorded as a DecisionError on the
|
|
||||||
privileged-evidence path, not as named stance fields (stage, failure
|
|
||||||
mode, decision id present only where rendered). Closing that gap is
|
|
||||||
SECRETS-WP-0008-T01.
|
|
||||||
|
|
||||||
# §6.4 obligation 2 — the verdict is never cached. A retry of the same
|
# §6.4 obligation 2 — the verdict is never cached. A retry of the same
|
||||||
# canonical request digest inside the decision lifetime is the decision
|
# canonical request digest inside the decision lifetime is the decision
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,11 @@ def main() -> int:
|
||||||
ok = False
|
ok = False
|
||||||
print(f"FINDING: pep_shaped but {decl['pep_stance']} is missing (§6.4 / §13.1)")
|
print(f"FINDING: pep_shaped but {decl['pep_stance']} is missing (§6.4 / §13.1)")
|
||||||
|
|
||||||
|
classification = decl.get("evidence_bound", {}).get("classification")
|
||||||
|
if classification and not (ROOT / str(classification)).is_file():
|
||||||
|
ok = False
|
||||||
|
print(f"FINDING: evidence classification {classification} is missing (§9.6)")
|
||||||
|
|
||||||
found = scan_bao_modules()
|
found = scan_bao_modules()
|
||||||
owned = owned_modules(decl)
|
owned = owned_modules(decl)
|
||||||
undeclared = {name: lines for name, lines in found.items() if name not in owned}
|
undeclared = {name: lines for name, lines in found.items() if name not in owned}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Command surface (FR7):
|
||||||
revoke <catalog-id>
|
revoke <catalog-id>
|
||||||
lifecycle suspend|deactivate|destroy <catalog-id>
|
lifecycle suspend|deactivate|destroy <catalog-id>
|
||||||
audit <catalog-id> [--json]
|
audit <catalog-id> [--json]
|
||||||
|
evidence heartbeat|drain|classify
|
||||||
|
|
||||||
Every privileged action is decision-gated and writes non-secret evidence.
|
Every privileged action is decision-gated and writes non-secret evidence.
|
||||||
`plan` and `apply --dry-run` never mutate OpenBao.
|
`plan` and `apply --dry-run` never mutate OpenBao.
|
||||||
|
|
@ -21,10 +22,8 @@ Every privileged action is decision-gated and writes non-secret evidence.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from secrets_engine import __version__
|
from secrets_engine import __version__
|
||||||
from secrets_engine.apply import apply_plan
|
from secrets_engine.apply import apply_plan
|
||||||
|
|
@ -33,6 +32,7 @@ from secrets_engine.config import Config, repo_root
|
||||||
from secrets_engine.decisions import require_approved, resolve_decision
|
from secrets_engine.decisions import require_approved, resolve_decision
|
||||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||||
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
||||||
|
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
||||||
from secrets_engine.openbao import OpenBaoClient
|
from secrets_engine.openbao import OpenBaoClient
|
||||||
from secrets_engine.plan import build_plan
|
from secrets_engine.plan import build_plan
|
||||||
from secrets_engine.provision import provision_from_file, provision_generated
|
from secrets_engine.provision import provision_from_file, provision_generated
|
||||||
|
|
@ -85,28 +85,23 @@ def _privileged_evidence(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _unsafe_local_demo_enabled(cfg: Config) -> bool:
|
def _require_lane_approval(
|
||||||
"""Return true only for an explicit, offline, loopback-only demo."""
|
cfg: Config,
|
||||||
host = (urlparse(cfg.bao_addr).hostname or "").lower()
|
entry,
|
||||||
return (
|
action: str = "",
|
||||||
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
evidence: PrivilegedActionEvidence | None = None,
|
||||||
and not cfg.hub_url
|
):
|
||||||
and host in {"127.0.0.1", "localhost", "::1"}
|
"""Apply published PEP stance, then resolve lane approval.
|
||||||
)
|
|
||||||
|
|
||||||
|
Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable
|
||||||
def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
access-engine decision record is not served yet, so that row refuses live
|
||||||
"""Resolve approval for a live action, failing production closed.
|
production work. The three-factor unsafe-demo exception is not a stance
|
||||||
|
row. Build/test ``fail_open`` still requires the existing lane-approval
|
||||||
The durable State Hub action-authorization endpoint is not available yet.
|
check — a tracked gap until SECRETS-WP-0008-T02.
|
||||||
Production therefore cannot rely on a coarse lane decision. The one narrow
|
|
||||||
exception is an explicit offline demo against a loopback OpenBao instance.
|
|
||||||
"""
|
"""
|
||||||
if entry.stage == "prod" and not _unsafe_local_demo_enabled(cfg):
|
stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown")
|
||||||
raise DecisionError(
|
if evidence is not None:
|
||||||
f"production action '{action or 'unknown'}' requires a durable "
|
evidence.mark_stance(stance)
|
||||||
"State Hub action authorization; live production remains disabled"
|
|
||||||
)
|
|
||||||
if not entry.approval_required():
|
if not entry.approval_required():
|
||||||
return None
|
return None
|
||||||
decision = resolve_decision(
|
decision = resolve_decision(
|
||||||
|
|
@ -115,6 +110,8 @@ def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
||||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||||
)
|
)
|
||||||
require_approved(entry, decision)
|
require_approved(entry, decision)
|
||||||
|
if evidence is not None:
|
||||||
|
evidence.mark_stance(with_decision(stance, decision))
|
||||||
return decision
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -229,7 +226,7 @@ def cmd_apply(cfg: Config, args) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "apply")
|
decision = _require_lane_approval(cfg, entry, "apply", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
plan = build_plan(
|
plan = build_plan(
|
||||||
entry, args.stage, decision_id=decision.id if decision else ""
|
entry, args.stage, decision_id=decision.id if decision else ""
|
||||||
|
|
@ -258,7 +255,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "provision")
|
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -291,7 +288,7 @@ def cmd_verify(cfg: Config, args) -> int:
|
||||||
"negative_requested": negative,
|
"negative_requested": negative,
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "verify")
|
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -367,7 +364,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "handoff")
|
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -419,7 +416,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
# require approval + readiness before running.
|
# require approval + readiness before running.
|
||||||
decision = _require_lane_approval(cfg, entry, "exec")
|
decision = _require_lane_approval(cfg, entry, "exec", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
if not args.command:
|
if not args.command:
|
||||||
from secrets_engine.errors import DeliveryError
|
from secrets_engine.errors import DeliveryError
|
||||||
|
|
@ -498,7 +495,7 @@ def cmd_revoke(cfg: Config, args) -> int:
|
||||||
with _privileged_evidence(
|
with _privileged_evidence(
|
||||||
cfg, entry, "revoke", detail={"operation": plan.operation}
|
cfg, entry, "revoke", detail={"operation": plan.operation}
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "deactivate")
|
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -546,7 +543,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
||||||
"live destroy is disabled until an exact-action destruction "
|
"live destroy is disabled until an exact-action destruction "
|
||||||
"approval contract is available; use --dry-run to inspect targets"
|
"approval contract is available; use --dry-run to inspect targets"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, args.operation)
|
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -579,6 +576,65 @@ def cmd_audit(cfg: Config, args) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_evidence_heartbeat(cfg: Config, args) -> int:
|
||||||
|
"""Write a positive load-bearing heartbeat. Not a permission check."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from secrets_engine.evidence import write_heartbeat
|
||||||
|
|
||||||
|
record = write_heartbeat(_writer(cfg), stage=args.stage)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(record, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"heartbeat {record['result']} queued={record.get('outbox_queued')} "
|
||||||
|
f"completeness_claimed={record.get('completeness_claimed')}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_evidence_drain(cfg: Config, args) -> int:
|
||||||
|
"""Best-effort drain of the local load-bearing outbox. Never a gate."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from secrets_engine.evidence import drain_outbox
|
||||||
|
|
||||||
|
result = drain_outbox(_writer(cfg), audit_core_url=args.audit_core_url)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"outbox drain queued={result['queued']} "
|
||||||
|
f"delivered={result['delivered']} failed={result['failed']} "
|
||||||
|
f"skipped={result['skipped']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_evidence_classify(cfg: Config, args) -> int:
|
||||||
|
import json
|
||||||
|
|
||||||
|
from secrets_engine.evidence_class import classify
|
||||||
|
|
||||||
|
classified = classify(args.action, args.stage)
|
||||||
|
payload = {
|
||||||
|
"action": classified.action,
|
||||||
|
"stage": classified.stage,
|
||||||
|
"kind": classified.kind,
|
||||||
|
"rule_id": classified.rule_id,
|
||||||
|
"queued_locally": classified.queued_locally,
|
||||||
|
"completeness_claimed": classified.completeness_claimed,
|
||||||
|
}
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{classified.action}/{classified.stage}: {classified.kind} "
|
||||||
|
f"rule={classified.rule_id} queued={classified.queued_locally}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# -- parser ----------------------------------------------------------------
|
# -- parser ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -700,6 +756,32 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
au.add_argument("--json", action="store_true")
|
au.add_argument("--json", action="store_true")
|
||||||
au.set_defaults(func=cmd_audit)
|
au.set_defaults(func=cmd_audit)
|
||||||
|
|
||||||
|
ev = sub.add_parser("evidence", help="load-bearing evidence heartbeat and outbox")
|
||||||
|
evsub = ev.add_subparsers(dest="subcmd", required=True)
|
||||||
|
hb = evsub.add_parser(
|
||||||
|
"heartbeat",
|
||||||
|
help="emit a positive nothing-to-report claim (not a permission check)",
|
||||||
|
)
|
||||||
|
hb.add_argument("--stage", default="prod", choices=("build", "test", "prod"))
|
||||||
|
hb.add_argument("--json", action="store_true")
|
||||||
|
hb.set_defaults(func=cmd_evidence_heartbeat)
|
||||||
|
dr = evsub.add_parser(
|
||||||
|
"drain",
|
||||||
|
help="best-effort drain of the local outbox; never blocks a mutation",
|
||||||
|
)
|
||||||
|
dr.add_argument(
|
||||||
|
"--audit-core-url",
|
||||||
|
default="",
|
||||||
|
help="optional audit-core base URL; empty skips delivery and keeps files",
|
||||||
|
)
|
||||||
|
dr.add_argument("--json", action="store_true")
|
||||||
|
dr.set_defaults(func=cmd_evidence_drain)
|
||||||
|
cl = evsub.add_parser("classify", help="show the §9.6 class for an action/stage")
|
||||||
|
cl.add_argument("action")
|
||||||
|
cl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||||
|
cl.add_argument("--json", action="store_true")
|
||||||
|
cl.set_defaults(func=cmd_evidence_classify)
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ class DecisionError(SecretsEngineError):
|
||||||
|
|
||||||
exit_code = 3
|
exit_code = 3
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, stance: dict[str, object] | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.stance = dict(stance or {})
|
||||||
|
|
||||||
|
|
||||||
class PolicyGuardError(SecretsEngineError):
|
class PolicyGuardError(SecretsEngineError):
|
||||||
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""
|
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||||
|
from secrets_engine.evidence_class import KIND_ATTRIBUTIVE, classify
|
||||||
from secrets_engine.redact import looks_secret, redact_text
|
from secrets_engine.redact import looks_secret, redact_text
|
||||||
|
|
||||||
# Keys that must never carry a value into evidence regardless of nesting.
|
# Keys that must never carry a value into evidence regardless of nesting.
|
||||||
|
|
@ -64,6 +65,21 @@ class EvidenceWriter:
|
||||||
with path.open("a", encoding="utf-8") as fh:
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
def outbox_dir(self) -> Path:
|
||||||
|
path = self.evidence_dir / "outbox"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _queue_outbox(self, record: dict[str, Any]) -> str:
|
||||||
|
"""Durably queue a load-bearing record. Never talks to audit-core."""
|
||||||
|
record_id = str(record.get("record_id") or uuid.uuid4())
|
||||||
|
path = self.outbox_dir() / f"{record_id}.json"
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
tmp.replace(path)
|
||||||
|
return record_id
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
self,
|
self,
|
||||||
action: str,
|
action: str,
|
||||||
|
|
@ -75,9 +91,18 @@ class EvidenceWriter:
|
||||||
detail: dict[str, Any] | None = None,
|
detail: dict[str, Any] | None = None,
|
||||||
hub: bool = True,
|
hub: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Append one non-secret evidence record. Returns the stored record."""
|
"""Append one non-secret evidence record. Returns the stored record.
|
||||||
|
|
||||||
|
Load-bearing records are queued locally first. An audit-core outage
|
||||||
|
cannot occur here because this method never contacts audit-core.
|
||||||
|
Completeness is never claimed. Presence or absence of a record is
|
||||||
|
not consulted as a permission.
|
||||||
|
"""
|
||||||
record_id = str(uuid.uuid4())
|
record_id = str(uuid.uuid4())
|
||||||
hub_requested = bool(hub and self.hub_url)
|
evidence_class = classify(action, stage)
|
||||||
|
hub_requested = bool(
|
||||||
|
hub and self.hub_url and evidence_class.kind == KIND_ATTRIBUTIVE
|
||||||
|
)
|
||||||
record = {
|
record = {
|
||||||
"record_id": record_id,
|
"record_id": record_id,
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|
@ -89,7 +114,13 @@ class EvidenceWriter:
|
||||||
"decision_id": decision_id,
|
"decision_id": decision_id,
|
||||||
"detail": _scrub(detail or {}),
|
"detail": _scrub(detail or {}),
|
||||||
"hub_delivery_requested": hub_requested,
|
"hub_delivery_requested": hub_requested,
|
||||||
|
"evidence_kind": evidence_class.kind,
|
||||||
|
"evidence_rule": evidence_class.rule_id,
|
||||||
|
"completeness_claimed": False,
|
||||||
}
|
}
|
||||||
|
if evidence_class.queued_locally:
|
||||||
|
self._queue_outbox(record)
|
||||||
|
record["outbox_queued"] = True
|
||||||
self._append_local(record)
|
self._append_local(record)
|
||||||
if hub_requested:
|
if hub_requested:
|
||||||
delivery = self._post_hub(
|
delivery = self._post_hub(
|
||||||
|
|
@ -205,6 +236,7 @@ class PrivilegedActionEvidence:
|
||||||
decision_id: str = ""
|
decision_id: str = ""
|
||||||
approval_status: str = "pending"
|
approval_status: str = "pending"
|
||||||
completed: bool = False
|
completed: bool = False
|
||||||
|
stance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.approval_required:
|
if not self.approval_required:
|
||||||
|
|
@ -218,6 +250,8 @@ class PrivilegedActionEvidence:
|
||||||
"decision_ref": self.decision_ref,
|
"decision_ref": self.decision_ref,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if self.stance:
|
||||||
|
merged.update(self.stance)
|
||||||
if extra:
|
if extra:
|
||||||
merged.update(extra)
|
merged.update(extra)
|
||||||
return merged
|
return merged
|
||||||
|
|
@ -239,6 +273,17 @@ class PrivilegedActionEvidence:
|
||||||
self.decision_id = str(getattr(decision, "id", ""))
|
self.decision_id = str(getattr(decision, "id", ""))
|
||||||
self.approval_status = "approved"
|
self.approval_status = "approved"
|
||||||
|
|
||||||
|
def mark_stance(self, stance: object | None) -> None:
|
||||||
|
if stance is None:
|
||||||
|
return
|
||||||
|
if hasattr(stance, "as_evidence"):
|
||||||
|
payload = stance.as_evidence()
|
||||||
|
elif isinstance(stance, dict):
|
||||||
|
payload = stance
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self.stance = {key: value for key, value in payload.items() if value != ""}
|
||||||
|
|
||||||
def finish(
|
def finish(
|
||||||
self, result: str, *, detail: dict[str, Any] | None = None
|
self, result: str, *, detail: dict[str, Any] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -259,6 +304,8 @@ class PrivilegedActionEvidence:
|
||||||
return False
|
return False
|
||||||
if isinstance(exc, DecisionError):
|
if isinstance(exc, DecisionError):
|
||||||
self.approval_status = "rejected"
|
self.approval_status = "rejected"
|
||||||
|
if getattr(exc, "stance", None):
|
||||||
|
self.mark_stance(exc.stance)
|
||||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||||
result = "interrupted"
|
result = "interrupted"
|
||||||
elif isinstance(exc, SecretsEngineError):
|
elif isinstance(exc, SecretsEngineError):
|
||||||
|
|
@ -274,3 +321,76 @@ class PrivilegedActionEvidence:
|
||||||
detail=self._detail({"error_type": type(exc).__name__}),
|
detail=self._detail({"error_type": type(exc).__name__}),
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def write_heartbeat(writer: EvidenceWriter, *, stage: str = "prod") -> dict[str, Any]:
|
||||||
|
"""Positive claim that can go missing. Not a permission and not silence."""
|
||||||
|
queued = 0
|
||||||
|
outbox = writer.evidence_dir / "outbox"
|
||||||
|
if outbox.is_dir():
|
||||||
|
queued = sum(1 for path in outbox.glob("*.json") if path.is_file())
|
||||||
|
return writer.record(
|
||||||
|
"evidence-heartbeat",
|
||||||
|
result="nothing-to-report",
|
||||||
|
stage=stage,
|
||||||
|
detail={
|
||||||
|
"form": "heartbeat",
|
||||||
|
"outbox_depth": queued,
|
||||||
|
"completeness_claimed": False,
|
||||||
|
},
|
||||||
|
hub=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drain_outbox(
|
||||||
|
writer: EvidenceWriter,
|
||||||
|
*,
|
||||||
|
audit_core_url: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Best-effort delivery of queued load-bearing records.
|
||||||
|
|
||||||
|
Never called from a mutation path. An audit-core outage leaves files in
|
||||||
|
place and does not raise into a revoke/destroy handler.
|
||||||
|
"""
|
||||||
|
outbox = writer.evidence_dir / "outbox"
|
||||||
|
if not outbox.is_dir():
|
||||||
|
return {
|
||||||
|
"queued": 0,
|
||||||
|
"delivered": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"completeness_claimed": False,
|
||||||
|
}
|
||||||
|
files = sorted(path for path in outbox.glob("*.json") if path.is_file())
|
||||||
|
queued = len(files)
|
||||||
|
if not audit_core_url:
|
||||||
|
return {
|
||||||
|
"queued": queued,
|
||||||
|
"delivered": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": queued,
|
||||||
|
"completeness_claimed": False,
|
||||||
|
}
|
||||||
|
delivered = 0
|
||||||
|
failed = 0
|
||||||
|
for path in files:
|
||||||
|
try:
|
||||||
|
payload = path.read_text(encoding="utf-8").encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
audit_core_url.rstrip("/") + "/v1/events",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=3)
|
||||||
|
path.unlink()
|
||||||
|
delivered += 1
|
||||||
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
|
failed += 1
|
||||||
|
return {
|
||||||
|
"queued": queued,
|
||||||
|
"delivered": delivered,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped": 0,
|
||||||
|
"completeness_claimed": False,
|
||||||
|
}
|
||||||
|
|
|
||||||
164
src/secrets_engine/evidence_class.py
Normal file
164
src/secrets_engine/evidence_class.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"""Load-bearing vs attributive evidence classification (§9.6).
|
||||||
|
|
||||||
|
The YAML file is the declaration. ``SHIPPED_RULES`` is the pin that makes
|
||||||
|
drift fail the test. No function here grants or denies an action based on
|
||||||
|
whether a local evidence record exists.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from secrets_engine.config import repo_root
|
||||||
|
from secrets_engine.errors import SecretsEngineError
|
||||||
|
|
||||||
|
KIND_LOAD_BEARING = "load-bearing"
|
||||||
|
KIND_ATTRIBUTIVE = "attributive"
|
||||||
|
KIND_HEARTBEAT = "heartbeat"
|
||||||
|
|
||||||
|
# First match wins. Destroy is always load-bearing; production control
|
||||||
|
# mutations are load-bearing; everything else is attributive.
|
||||||
|
SHIPPED_RULES = (
|
||||||
|
{
|
||||||
|
"id": "heartbeat",
|
||||||
|
"kind": KIND_HEARTBEAT,
|
||||||
|
"actions": ("evidence-heartbeat",),
|
||||||
|
"stages": ("build", "test", "prod", "unknown"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "destroy",
|
||||||
|
"kind": KIND_LOAD_BEARING,
|
||||||
|
"actions": ("lifecycle-destroy",),
|
||||||
|
"stages": ("build", "test", "prod", "unknown"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "production-control-mutation",
|
||||||
|
"kind": KIND_LOAD_BEARING,
|
||||||
|
"actions": (
|
||||||
|
"revoke",
|
||||||
|
"lifecycle-suspend",
|
||||||
|
"lifecycle-deactivate",
|
||||||
|
"provision",
|
||||||
|
),
|
||||||
|
"stages": ("prod",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "default-attributive",
|
||||||
|
"kind": KIND_ATTRIBUTIVE,
|
||||||
|
"actions": ("*",),
|
||||||
|
"stages": ("build", "test", "prod", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceClassificationError(SecretsEngineError):
|
||||||
|
"""Classification file missing or malformed."""
|
||||||
|
|
||||||
|
exit_code = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvidenceClass:
|
||||||
|
kind: str
|
||||||
|
rule_id: str
|
||||||
|
action: str
|
||||||
|
stage: str
|
||||||
|
queued_locally: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completeness_claimed(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classification_path() -> Path:
|
||||||
|
override = os.environ.get("SECRETS_ENGINE_EVIDENCE_CLASSIFICATION", "")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return repo_root() / "evidence-classification.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rules(raw: Any) -> tuple[dict[str, Any], ...]:
|
||||||
|
if not isinstance(raw, list) or not raw:
|
||||||
|
raise EvidenceClassificationError(
|
||||||
|
"evidence-classification.yaml must list at least one rule"
|
||||||
|
)
|
||||||
|
rules: list[dict[str, Any]] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise EvidenceClassificationError("each classification rule must be a map")
|
||||||
|
kind = str(item.get("kind", ""))
|
||||||
|
if kind not in {KIND_LOAD_BEARING, KIND_ATTRIBUTIVE, KIND_HEARTBEAT}:
|
||||||
|
raise EvidenceClassificationError(f"unknown evidence kind {kind!r}")
|
||||||
|
actions = tuple(str(a) for a in item.get("actions") or ())
|
||||||
|
stages = tuple(str(s) for s in item.get("stages") or ())
|
||||||
|
if not actions or not stages:
|
||||||
|
raise EvidenceClassificationError(
|
||||||
|
f"rule {item.get('id')!r} needs actions and stages"
|
||||||
|
)
|
||||||
|
rules.append(
|
||||||
|
{
|
||||||
|
"id": str(item.get("id", "")),
|
||||||
|
"kind": kind,
|
||||||
|
"actions": actions,
|
||||||
|
"stages": stages,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(rules)
|
||||||
|
|
||||||
|
|
||||||
|
def load_classification_rules(
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
target = path or classification_path()
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise EvidenceClassificationError(
|
||||||
|
f"unable to load evidence classification {target}: {exc}"
|
||||||
|
) from exc
|
||||||
|
if data.get("completeness_claimed") is not False:
|
||||||
|
raise EvidenceClassificationError(
|
||||||
|
f"{target} must declare completeness_claimed: false"
|
||||||
|
)
|
||||||
|
if data.get("no_control_branches_on_presence") is not True:
|
||||||
|
raise EvidenceClassificationError(
|
||||||
|
f"{target} must declare no_control_branches_on_presence: true"
|
||||||
|
)
|
||||||
|
return _normalize_rules(data.get("rules"))
|
||||||
|
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
action: str,
|
||||||
|
stage: str,
|
||||||
|
*,
|
||||||
|
rules: tuple[dict[str, Any], ...] | None = None,
|
||||||
|
) -> EvidenceClass:
|
||||||
|
"""Return the evidence class for an action/stage. Never a permission."""
|
||||||
|
table = rules if rules is not None else load_classification_rules()
|
||||||
|
stage_key = stage if stage else "unknown"
|
||||||
|
for rule in table:
|
||||||
|
actions = rule["actions"]
|
||||||
|
stages = rule["stages"]
|
||||||
|
if "*" not in actions and action not in actions:
|
||||||
|
continue
|
||||||
|
if stage_key not in stages and "*" not in stages:
|
||||||
|
continue
|
||||||
|
kind = str(rule["kind"])
|
||||||
|
return EvidenceClass(
|
||||||
|
kind=kind,
|
||||||
|
rule_id=str(rule["id"]),
|
||||||
|
action=action,
|
||||||
|
stage=stage_key,
|
||||||
|
queued_locally=kind in {KIND_LOAD_BEARING, KIND_HEARTBEAT},
|
||||||
|
)
|
||||||
|
return EvidenceClass(
|
||||||
|
kind=KIND_ATTRIBUTIVE,
|
||||||
|
rule_id="implicit-attributive",
|
||||||
|
action=action,
|
||||||
|
stage=stage_key,
|
||||||
|
queued_locally=False,
|
||||||
|
)
|
||||||
141
src/secrets_engine/pep_stance.py
Normal file
141
src/secrets_engine/pep_stance.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""Published PEP unreachable-engine stance (security-layer-model v0.7 §6.4).
|
||||||
|
|
||||||
|
Runtime reads ``pep-stance.yaml``. ``SHIPPED_STANCE`` is the pin that makes
|
||||||
|
drift between the published map and this module fail the conformance test.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from secrets_engine.config import repo_root
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
|
SHIPPED_STANCE = {
|
||||||
|
"build": "fail_open",
|
||||||
|
"test": "fail_open",
|
||||||
|
"prod": "fail_closed",
|
||||||
|
"unknown": "fail_closed",
|
||||||
|
}
|
||||||
|
REQUIRED_STAGES = tuple(SHIPPED_STANCE)
|
||||||
|
VALID_MODES = frozenset({"fail_open", "fail_closed"})
|
||||||
|
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StanceApplication:
|
||||||
|
"""Named residue applied when access-engine is unreachable."""
|
||||||
|
|
||||||
|
stage: str
|
||||||
|
failure_mode: str
|
||||||
|
action: str
|
||||||
|
demo_exception: bool = False
|
||||||
|
decision_id: str = ""
|
||||||
|
|
||||||
|
def as_evidence(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"stance_stage": self.stage,
|
||||||
|
"stance_failure_mode": self.failure_mode,
|
||||||
|
"stance_demo_exception": self.demo_exception,
|
||||||
|
}
|
||||||
|
if self.decision_id:
|
||||||
|
payload["stance_decision_id"] = self.decision_id
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PepStanceMap:
|
||||||
|
stance: dict[str, str]
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
def for_stage(self, stage: str) -> tuple[str, str]:
|
||||||
|
key = stage if stage in self.stance else "unknown"
|
||||||
|
mode = self.stance.get(key, "")
|
||||||
|
if mode not in VALID_MODES:
|
||||||
|
raise DecisionError(
|
||||||
|
f"pep-stance.yaml has no usable mode for stage {stage!r}"
|
||||||
|
)
|
||||||
|
return key, mode
|
||||||
|
|
||||||
|
|
||||||
|
def pep_stance_path() -> Path:
|
||||||
|
override = os.environ.get("SECRETS_ENGINE_PEP_STANCE", "")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return repo_root() / "pep-stance.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_pep_stance(path: Path | None = None) -> PepStanceMap:
|
||||||
|
target = path or pep_stance_path()
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise DecisionError(f"unable to load PEP stance map {target}: {exc}") from exc
|
||||||
|
raw = data.get("stance")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise DecisionError(f"{target} is missing a stance map")
|
||||||
|
stance = {str(key): str(value) for key, value in raw.items()}
|
||||||
|
missing = [stage for stage in REQUIRED_STAGES if stage not in stance]
|
||||||
|
if missing:
|
||||||
|
raise DecisionError(
|
||||||
|
f"{target} is not total; missing stages {missing}"
|
||||||
|
)
|
||||||
|
unknown_modes = {
|
||||||
|
f"{stage}={mode}"
|
||||||
|
for stage, mode in stance.items()
|
||||||
|
if mode not in VALID_MODES
|
||||||
|
}
|
||||||
|
if unknown_modes:
|
||||||
|
raise DecisionError(f"{target} has invalid modes: {sorted(unknown_modes)}")
|
||||||
|
return PepStanceMap(stance=stance, path=target)
|
||||||
|
|
||||||
|
|
||||||
|
def demo_exception_enabled(cfg: Any) -> bool:
|
||||||
|
"""Three-factor throwaway exception; not a stance row."""
|
||||||
|
host = (urlparse(getattr(cfg, "bao_addr", "")).hostname or "").lower()
|
||||||
|
return (
|
||||||
|
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||||
|
and not getattr(cfg, "hub_url", "")
|
||||||
|
and host in LOOPBACK_HOSTS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_unreachable_engine_stance(
|
||||||
|
cfg: Any,
|
||||||
|
entry: Any,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
stance_map: PepStanceMap | None = None,
|
||||||
|
) -> StanceApplication:
|
||||||
|
"""Apply the published unreachable-engine residue for a live action.
|
||||||
|
|
||||||
|
``fail_closed`` without the demo exception raises ``DecisionError`` carrying
|
||||||
|
named stance fields. ``fail_open`` is the documented residue: continue to
|
||||||
|
the existing lane-approval check, which is itself a gap until T02.
|
||||||
|
"""
|
||||||
|
loaded = stance_map or load_pep_stance()
|
||||||
|
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
|
||||||
|
demo = demo_exception_enabled(cfg)
|
||||||
|
applied = StanceApplication(
|
||||||
|
stage=stage,
|
||||||
|
failure_mode=mode,
|
||||||
|
action=action or "unknown",
|
||||||
|
demo_exception=bool(demo and mode == "fail_closed"),
|
||||||
|
)
|
||||||
|
if mode == "fail_closed" and not demo:
|
||||||
|
raise DecisionError(
|
||||||
|
f"production action '{applied.action}' requires a durable "
|
||||||
|
"access-engine decision record; live production remains disabled",
|
||||||
|
stance=applied.as_evidence(),
|
||||||
|
)
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
def with_decision(stance: StanceApplication, decision: Any) -> StanceApplication:
|
||||||
|
decision_id = str(getattr(decision, "id", "") or "")
|
||||||
|
return replace(stance, decision_id=decision_id)
|
||||||
102
tests/test_evidence_class.py
Normal file
102
tests/test_evidence_class.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""§9.6 classification, local outbox, heartbeat, and drain."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from secrets_engine.evidence import EvidenceWriter, drain_outbox, write_heartbeat
|
||||||
|
from secrets_engine.evidence_class import classify
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bearing_record_is_queued_locally_before_jsonl(tmp_path):
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="http://hub.invalid", topic_id="t")
|
||||||
|
record = writer.record(
|
||||||
|
"lifecycle-destroy",
|
||||||
|
result="attempt",
|
||||||
|
catalog_id="lane",
|
||||||
|
stage="prod",
|
||||||
|
detail={"value": "should-not-leak"},
|
||||||
|
hub=True,
|
||||||
|
)
|
||||||
|
assert record["evidence_kind"] == "load-bearing"
|
||||||
|
assert record["completeness_claimed"] is False
|
||||||
|
assert record["outbox_queued"] is True
|
||||||
|
assert record["hub_delivery_requested"] is False
|
||||||
|
queued = list((tmp_path / "outbox").glob("*.json"))
|
||||||
|
assert len(queued) == 1
|
||||||
|
queued_payload = json.loads(queued[0].read_text())
|
||||||
|
assert queued_payload["record_id"] == record["record_id"]
|
||||||
|
assert "should-not-leak" not in queued[0].read_text()
|
||||||
|
jsonl = next(tmp_path.glob("evidence-*.jsonl")).read_text()
|
||||||
|
assert record["record_id"] in jsonl
|
||||||
|
|
||||||
|
|
||||||
|
def test_attributive_record_is_not_queued_and_may_post_hub(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda *_args, **_kwargs: SimpleNamespace(status=200, read=lambda: b"{}"),
|
||||||
|
)
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="http://hub.invalid", topic_id="t")
|
||||||
|
record = writer.record("apply", result="applied", catalog_id="lane", stage="prod")
|
||||||
|
assert record["evidence_kind"] == "attributive"
|
||||||
|
assert record["hub_delivery_requested"] is True
|
||||||
|
assert not list((tmp_path / "outbox").glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_is_a_positive_claim_not_a_permission(tmp_path):
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||||
|
record = write_heartbeat(writer, stage="prod")
|
||||||
|
assert record["action"] == "evidence-heartbeat"
|
||||||
|
assert record["result"] == "nothing-to-report"
|
||||||
|
assert record["evidence_kind"] == "heartbeat"
|
||||||
|
assert record["completeness_claimed"] is False
|
||||||
|
assert record["outbox_queued"] is True
|
||||||
|
# Heartbeat must not be used as "no jsonl means nothing happened".
|
||||||
|
assert classify("evidence-heartbeat", "prod").kind == "heartbeat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_without_audit_core_keeps_files(tmp_path):
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||||
|
writer.record("provision", result="from-file", catalog_id="lane", stage="prod")
|
||||||
|
result = drain_outbox(writer, audit_core_url="")
|
||||||
|
assert result["queued"] == 1
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
assert result["delivered"] == 0
|
||||||
|
assert result["completeness_claimed"] is False
|
||||||
|
assert list((tmp_path / "outbox").glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_audit_core_outage_does_not_raise_or_delete(tmp_path, monkeypatch):
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||||
|
writer.record("revoke", result="native-access-deactivated", catalog_id="lane", stage="prod")
|
||||||
|
|
||||||
|
def offline(*_args, **_kwargs):
|
||||||
|
raise urllib.error.URLError("audit-core down")
|
||||||
|
|
||||||
|
monkeypatch.setattr("urllib.request.urlopen", offline)
|
||||||
|
result = drain_outbox(writer, audit_core_url="http://audit-core.invalid")
|
||||||
|
assert result["failed"] == 1
|
||||||
|
assert result["delivered"] == 0
|
||||||
|
assert list((tmp_path / "outbox").glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_drain_removes_only_delivered_files(tmp_path, monkeypatch):
|
||||||
|
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||||
|
writer.record("provision", result="from-file", catalog_id="lane", stage="prod")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda *_args, **_kwargs: SimpleNamespace(status=202, read=lambda: b"{}"),
|
||||||
|
)
|
||||||
|
result = drain_outbox(writer, audit_core_url="http://audit-core.invalid")
|
||||||
|
assert result["delivered"] == 1
|
||||||
|
assert not list((tmp_path / "outbox").glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_is_not_a_permission_api():
|
||||||
|
"""Missing or unknown actions are attributive labels, never a deny."""
|
||||||
|
unknown = classify("not-a-control", "prod")
|
||||||
|
assert unknown.kind == "attributive"
|
||||||
|
assert unknown.completeness_claimed is False
|
||||||
|
assert "permit" not in dir(classify)
|
||||||
|
assert "deny" not in dir(classify)
|
||||||
|
|
@ -17,6 +17,8 @@ import yaml
|
||||||
from secrets_engine.catalog import validate_entry
|
from secrets_engine.catalog import validate_entry
|
||||||
from secrets_engine.cli import _require_lane_approval
|
from secrets_engine.cli import _require_lane_approval
|
||||||
from secrets_engine.errors import DecisionError
|
from secrets_engine.errors import DecisionError
|
||||||
|
from secrets_engine.evidence_class import SHIPPED_RULES, classify, load_classification_rules
|
||||||
|
from secrets_engine.pep_stance import SHIPPED_STANCE, load_pep_stance
|
||||||
|
|
||||||
from tests.test_catalog import VALID
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
|
@ -25,6 +27,7 @@ SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
|
||||||
DECL = ROOT / "layer.yaml"
|
DECL = ROOT / "layer.yaml"
|
||||||
STANCE = ROOT / "pep-stance.yaml"
|
STANCE = ROOT / "pep-stance.yaml"
|
||||||
INTENT = ROOT / "INTENT.md"
|
INTENT = ROOT / "INTENT.md"
|
||||||
|
CLASSIFICATION = ROOT / "evidence-classification.yaml"
|
||||||
|
|
||||||
|
|
||||||
def _decl() -> dict:
|
def _decl() -> dict:
|
||||||
|
|
@ -73,6 +76,7 @@ def test_proposed_capabilities_carry_gap_record_fields():
|
||||||
assert cap.get(field), f"{cap.get('id')} missing {field}"
|
assert cap.get(field), f"{cap.get('id')} missing {field}"
|
||||||
assert cap["state"] == "unowned-capability"
|
assert cap["state"] == "unowned-capability"
|
||||||
assert cap["owner_status"] == "proposed"
|
assert cap["owner_status"] == "proposed"
|
||||||
|
assert cap.get("contract"), f"{cap.get('id')} missing contract"
|
||||||
|
|
||||||
|
|
||||||
def test_stance_map_is_total_over_catalog_stages():
|
def test_stance_map_is_total_over_catalog_stages():
|
||||||
|
|
@ -85,19 +89,53 @@ def test_stance_map_is_total_over_catalog_stages():
|
||||||
assert _stance()["verdict_caching"] == "none"
|
assert _stance()["verdict_caching"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_map_equals_shipped_constant_and_loader():
|
||||||
|
"""Changing the YAML without changing SHIPPED_STANCE fails, and the reverse."""
|
||||||
|
published = _stance()["stance"]
|
||||||
|
loaded = load_pep_stance().stance
|
||||||
|
assert published == SHIPPED_STANCE
|
||||||
|
assert loaded == SHIPPED_STANCE
|
||||||
|
|
||||||
|
|
||||||
def test_published_prod_stance_equals_shipped_fail_closed(monkeypatch):
|
def test_published_prod_stance_equals_shipped_fail_closed(monkeypatch):
|
||||||
"""pep-stance.yaml prod: fail_closed must equal _require_lane_approval."""
|
"""Runtime reads pep-stance.yaml; prod fail_closed must equal the gate."""
|
||||||
assert _stance()["stance"]["prod"] == "fail_closed"
|
assert load_pep_stance().stance["prod"] == "fail_closed"
|
||||||
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
||||||
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
||||||
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
with pytest.raises(DecisionError, match="live production remains disabled"):
|
with pytest.raises(DecisionError, match="live production remains disabled") as raised:
|
||||||
_require_lane_approval(cfg, entry, "apply")
|
_require_lane_approval(cfg, entry, "apply")
|
||||||
|
assert raised.value.stance["stance_stage"] == "prod"
|
||||||
|
assert raised.value.stance["stance_failure_mode"] == "fail_closed"
|
||||||
|
assert "stance_decision_id" not in raised.value.stance
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_is_the_runtime_source(tmp_path, monkeypatch):
|
||||||
|
"""A published map the pin does not match is a test failure; runtime follows YAML."""
|
||||||
|
path = tmp_path / "pep-stance.yaml"
|
||||||
|
path.write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"stance": {
|
||||||
|
"build": "fail_open",
|
||||||
|
"test": "fail_open",
|
||||||
|
"prod": "fail_open",
|
||||||
|
"unknown": "fail_closed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("SECRETS_ENGINE_PEP_STANCE", str(path))
|
||||||
|
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
||||||
|
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
||||||
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
|
assert _require_lane_approval(cfg, entry, "apply") is None
|
||||||
|
|
||||||
|
|
||||||
def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatch):
|
def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatch):
|
||||||
"""build is fail_open relative to access-engine: lane approval still applies."""
|
"""build is fail_open relative to access-engine: lane approval still applies."""
|
||||||
assert _stance()["stance"]["build"] == "fail_open"
|
assert load_pep_stance().stance["build"] == "fail_open"
|
||||||
(tmp_path / ".decisions").mkdir()
|
(tmp_path / ".decisions").mkdir()
|
||||||
(tmp_path / ".decisions" / "x.yaml").write_text(
|
(tmp_path / ".decisions" / "x.yaml").write_text(
|
||||||
"id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n"
|
"id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n"
|
||||||
|
|
@ -114,4 +152,38 @@ def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatc
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
cfg = SimpleNamespace(hub_url="", bao_addr="http://127.0.0.1:8200")
|
cfg = SimpleNamespace(hub_url="", bao_addr="http://127.0.0.1:8200")
|
||||||
assert _require_lane_approval(cfg, entry, "apply").id == "x"
|
decision = _require_lane_approval(cfg, entry, "apply")
|
||||||
|
assert decision.id == "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classification_yaml_equals_shipped_rules():
|
||||||
|
loaded = load_classification_rules()
|
||||||
|
assert tuple(rule["id"] for rule in loaded) == tuple(rule["id"] for rule in SHIPPED_RULES)
|
||||||
|
assert tuple(rule["kind"] for rule in loaded) == tuple(rule["kind"] for rule in SHIPPED_RULES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_does_not_grant_permission():
|
||||||
|
prod_provision = classify("provision", "prod")
|
||||||
|
test_provision = classify("provision", "test")
|
||||||
|
destroy = classify("lifecycle-destroy", "build")
|
||||||
|
apply_prod = classify("apply", "prod")
|
||||||
|
heartbeat = classify("evidence-heartbeat", "prod")
|
||||||
|
assert prod_provision.kind == "load-bearing"
|
||||||
|
assert test_provision.kind == "attributive"
|
||||||
|
assert destroy.kind == "load-bearing"
|
||||||
|
assert apply_prod.kind == "attributive"
|
||||||
|
assert heartbeat.kind == "heartbeat"
|
||||||
|
assert prod_provision.completeness_claimed is False
|
||||||
|
assert CLASSIFICATION.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_proposed_contracts_exist_and_forbid_secret_material():
|
||||||
|
ssh = (ROOT / "docs/ssh-ca-signing-contract.md").read_text(encoding="utf-8")
|
||||||
|
secret_use = (ROOT / "docs/secret-use-evidence-contract.md").read_text(encoding="utf-8")
|
||||||
|
assert "proposed" in ssh.lower()
|
||||||
|
assert "proposed" in secret_use.lower()
|
||||||
|
assert "warden sign" in ssh
|
||||||
|
assert "private key" in ssh.lower() or "private keys" in ssh.lower()
|
||||||
|
assert "secret values" in secret_use.lower() or "secret value" in secret_use.lower()
|
||||||
|
assert "audit-core" in secret_use
|
||||||
|
assert "completeness is not claimed" in secret_use.lower()
|
||||||
|
|
|
||||||
|
|
@ -121,3 +121,12 @@ def test_production_handler_fails_closed_before_backend(tmp_path, monkeypatch):
|
||||||
"attempt",
|
"attempt",
|
||||||
"failed-DecisionError",
|
"failed-DecisionError",
|
||||||
]
|
]
|
||||||
|
terminal = records[-1]
|
||||||
|
assert terminal["detail"]["stance_stage"] == "prod"
|
||||||
|
assert terminal["detail"]["stance_failure_mode"] == "fail_closed"
|
||||||
|
assert terminal["detail"]["approval_status"] == "rejected"
|
||||||
|
assert "stance_decision_id" not in terminal["detail"]
|
||||||
|
assert terminal["completeness_claimed"] is False
|
||||||
|
assert "SUPER-SECRET" not in json.dumps(records)
|
||||||
|
outbox = list((tmp_path / "evidence" / "outbox").glob("*.json"))
|
||||||
|
assert outbox, "production provision refusal is load-bearing and must be queued"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Evolve the Lifecycle engine to the accepted security layer model"
|
title: "Evolve the Lifecycle engine to the accepted security layer model"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: secrets-engine
|
repo: secrets-engine
|
||||||
status: ready
|
status: active
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-29"
|
created: "2026-08-29"
|
||||||
|
|
@ -56,11 +56,16 @@ It adds the layer-model remainder: stance binding, decision-record consumption,
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0008-T01
|
id: SECRETS-WP-0008-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "945735c6-2d3d-5f72-baf0-692e6b210889"
|
state_hub_task_id: "945735c6-2d3d-5f72-baf0-692e6b210889"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Completed 2026-08-29. `_require_lane_approval` loads `pep-stance.yaml`.
|
||||||
|
`SHIPPED_STANCE` is pinned equal to the published map. Fail-closed production
|
||||||
|
evidence records `stance_stage`, `stance_failure_mode`, and omits
|
||||||
|
`stance_decision_id` unless a decision was rendered.
|
||||||
|
|
||||||
`pep-stance.yaml` is published and tests pin `prod: fail_closed` to
|
`pep-stance.yaml` is published and tests pin `prod: fail_closed` to
|
||||||
`_require_lane_approval`. The gate is still hardcoded, and a refusal is
|
`_require_lane_approval`. The gate is still hardcoded, and a refusal is
|
||||||
recorded as a `DecisionError` rather than named stance fields.
|
recorded as a `DecisionError` rather than named stance fields.
|
||||||
|
|
@ -111,11 +116,16 @@ Acceptance:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0008-T03
|
id: SECRETS-WP-0008-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0b54cedf-9335-5fa0-bdcb-1efa9a9cd98d"
|
state_hub_task_id: "0b54cedf-9335-5fa0-bdcb-1efa9a9cd98d"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Completed 2026-08-29. `evidence-classification.yaml` is the declaration.
|
||||||
|
Load-bearing records are queued in `evidence_dir/outbox/` before JSONL.
|
||||||
|
`secrets-engine evidence heartbeat` emits a positive claim. Drain never sits
|
||||||
|
on a mutation path and an audit-core outage leaves the queue in place.
|
||||||
|
|
||||||
Classify each privileged action as load-bearing or attributive. Today's trail
|
Classify each privileged action as load-bearing or attributive. Today's trail
|
||||||
is attributive local JSONL plus best-effort State Hub notes; completeness is
|
is attributive local JSONL plus best-effort State Hub notes; completeness is
|
||||||
already not claimed and must stay that way until the destination is
|
already not claimed and must stay that way until the destination is
|
||||||
|
|
@ -142,11 +152,14 @@ Acceptance:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0008-T04
|
id: SECRETS-WP-0008-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "581aeee3-3d99-5034-992b-7d42e6542a19"
|
state_hub_task_id: "581aeee3-3d99-5034-992b-7d42e6542a19"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Completed 2026-08-29. Contract published at `docs/ssh-ca-signing-contract.md`.
|
||||||
|
No OpenBao SSH-CA write was added. `layer.yaml` owner_status remains proposed.
|
||||||
|
|
||||||
ops-warden's `ssh-ca-signing-write` gap names this engine as intended owner.
|
ops-warden's `ssh-ca-signing-write` gap names this engine as intended owner.
|
||||||
Accept the **engine API**, decline the Staff lane. Publish a contract
|
Accept the **engine API**, decline the Staff lane. Publish a contract
|
||||||
ops-warden can call after an `access-engine` decision: sign a short-lived
|
ops-warden can call after an `access-engine` decision: sign a short-lived
|
||||||
|
|
@ -168,11 +181,15 @@ Acceptance:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0008-T05
|
id: SECRETS-WP-0008-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "3100d28c-9494-5b21-9d5a-af583cd8aa55"
|
state_hub_task_id: "3100d28c-9494-5b21-9d5a-af583cd8aa55"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Completed 2026-08-29. Contract published at
|
||||||
|
`docs/secret-use-evidence-contract.md`. `layer.yaml` owner_status remains
|
||||||
|
proposed. `route`/`audit` are still not this surface.
|
||||||
|
|
||||||
kings-guard's secret-abuse posture is fixture-driven because no engine exposes
|
kings-guard's secret-abuse posture is fixture-driven because no engine exposes
|
||||||
lease, revocation, mount, rotation, and delivery-session metadata. `route` and
|
lease, revocation, mount, rotation, and delivery-session metadata. `route` and
|
||||||
`audit` are operator summaries over local JSONL and are not that surface.
|
`audit` are operator summaries over local JSONL and are not that surface.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue