Record reviewed owner implementation and consumer compatibility (1)
This commit is contained in:
parent
05df7c3ee4
commit
518369dbee
18 changed files with 1990 additions and 0 deletions
|
|
@ -0,0 +1,81 @@
|
|||
# Platform broker source candidate
|
||||
|
||||
This is an isolated, uncommitted candidate against Platform
|
||||
`104ed90f42a9a86bf9fda343b49b65a85c9ee4b9`, with NetKingdom
|
||||
`befb0bc68cd25b625804fce559476eefd702521d` and Warden
|
||||
`b593c879831378db7dc62adfabdfedf2d44ddd38` as review inputs.
|
||||
No canonical Platform, NetKingdom or Warden files were changed. No owner assent,
|
||||
1.3 acknowledgment, case acceptance, credential, lane or native grant is created.
|
||||
|
||||
`platform-source.patch` modifies four existing Platform files; exact input and
|
||||
output hashes are in `manifest.json`, and the full candidate files are under
|
||||
`candidate/`. `workplan-source-draft.md` proposes a legitimate new Platform
|
||||
workplan with origin NK-WP-0048-T16 and four tasks, mapping the owner's review
|
||||
labels RPF-1.3-T1..T4 into source work records. RPF-WP-0060 was next free in the
|
||||
frozen source; recheck allocation before publishing. No managed UUIDs are set.
|
||||
|
||||
The broker uses an explicit owner-controlled revision map. Only the existing
|
||||
1.1 revision is admitted. The 1.2 source acknowledgment does not extend broker
|
||||
admission, and actual proposed 1.3 remains refused. Each accepted revision needs
|
||||
an exact type-source path and byte digest, exact reviewed parameter fingerprint,
|
||||
acknowledged/implemented status, matching id/version, and successful case/type
|
||||
validation against the authenticated producer closure. The three producer module
|
||||
pins match Warden BrowserInputs. No 1.3 parameter or source pin is introduced.
|
||||
Unknown, unbound, wrong-source, symlinked, proposed and drifted sources fail
|
||||
closed before native calls. The existing exact case-byte reference, materialized
|
||||
status, confidential-client and client-id checks remain required.
|
||||
|
||||
The producer is copied only after hash verification into a disposable tree and
|
||||
runs under `python -I` with an empty environment, following the existing Warden
|
||||
pattern. Sources come from the fixed sibling `net-kingdom` checkout. No CLI or
|
||||
environment-supplied root can select type sources. An installation without those
|
||||
exact source bytes now fails closed; source installation/layout must therefore
|
||||
be reviewed before integration. This deliberately makes availability depend on
|
||||
verified source rather than silently falling back to the old string check.
|
||||
|
||||
Catalog actors become human-operator only and the subject names the exact
|
||||
`auth/netkingdom/role/credential-broker-keycape-client-secret-generate` issuer
|
||||
binding. The regression checks the actual CCR-2026-0043 role file's
|
||||
net-kingdom-admins group and aal2 claim. No policy or issuer role changes.
|
||||
|
||||
This packet completes candidate type/source hardening and the catalog correction
|
||||
only. Review label RPF-1.3-T1 is not fully satisfied: authenticated admitted-case
|
||||
verification remains open, beyond reference/schema validation. T01 stays todo
|
||||
in the source draft, as do the other tasks pending owner integration/review.
|
||||
|
||||
`tests.log`: 15 broker tests and 4 catalog tests pass. Tests use a fake bao;
|
||||
bwrap supplies an isolated network namespace with no host home/config mounts,
|
||||
empty HOME and allowlisted environment. No credential, provider, model or native
|
||||
calls occur. The synthetic future-revision test uses owner-configuration mocks
|
||||
and an explicitly synthetic acknowledged type, proving that a new revision needs
|
||||
both admission bindings and acknowledgment; it is not a test of actual 1.3
|
||||
admission. The actual proposed 1.3 and source-only 1.2 refusal paths are tested.
|
||||
`baseline-reproduction.log` retains two expected failures against the original
|
||||
broker/catalog with the new regressions: the old broker accepts an unknown case
|
||||
parameter without producer validation; the old catalog permits approved-agent.
|
||||
The original hardcoded @1.1 check remains visible in the patch input and would
|
||||
refuse any successor regardless of its genuine future acknowledgment.
|
||||
|
||||
Reproduction uses archives extracted into `/tmp/platform-broker-r1/` with sibling
|
||||
`railiance-platform`, `net-kingdom` and `ops-warden` trees, and an empty
|
||||
`empty-home` directory. Apply the candidate patch to that Platform archive, then
|
||||
run `sh run-tests.sh`. Paths in the runner are intentionally exact. The baseline
|
||||
runner temporarily substitutes the two original Platform files in scratch and
|
||||
restores candidate bytes in a finally block; it never modifies canonical sources.
|
||||
|
||||
Limits and integration gates:
|
||||
|
||||
- Self-hashed case bytes and validation do not authenticate an owner's case
|
||||
acceptance. That existing semantic limit is unchanged; Warden/native evidence
|
||||
and the operator session must establish actual authorization independently.
|
||||
- A pinned acknowledged type and validation never authorize execution. There is
|
||||
no actual 1.3 binding, acknowledgment, owner-descriptor-acceptance or admission.
|
||||
- T3/T4 capabilities, attended destroy authority, evidence publication,
|
||||
revocations and platform-secret-delivery lane remain unimplemented. Their
|
||||
legitimate proposed task records are retained in the draft workplan.
|
||||
- Before source integration, independently review this patch, the fixed source
|
||||
deployment requirement, source pins and draft workplan. Recheck exact source
|
||||
heads, allocate the new Platform workplan, apply the reviewed candidate in that
|
||||
owner repository, rerun the offline checks, and use the owner's Repo Manager
|
||||
publication/projection procedure. This packet authorizes none of those native
|
||||
operations or later owner acceptance by itself.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
fb652cd9c77ae0f270f7775a17e01bc57498db52df40dd867028e0ecc3957cc5 README.md
|
||||
abcec396aee21d9f44dd949b3de58e91ff4c15452af62dc168354c7f5769dca2 baseline-reproduction.log
|
||||
7cfe12970a57ab75cb91c7c5024a4dcfc28ffae4b31a1afaf4d0cedabbb22886 candidate/credential-grants/catalog.yaml
|
||||
da80947a2c28df36b37e711d1e6d2f66b4c0434082e7bf187da3d3cb692cec28 candidate/scripts/keycape_client_secret_generate.py
|
||||
5c0cfcb039c0220e59dac7986d960bb568593a3a9281ff2557bb6c38de31e68a candidate/tests/test_credential_grants.py
|
||||
5906e32c7fe0db09c1f7db48ad61226497e062f38ebfa91e8a03be89272765c0 candidate/tests/test_keycape_client_secret_generate.py
|
||||
61c15ae8d12b3665eb0e210cc0a1b0a238f52026258a59987657fd77f8d6c0ba manifest.json
|
||||
3ecccb9cee2279b2f6d665fc7116421685262abbd7b0f82f59e3c8ed56892ba3 patch-check.log
|
||||
b0b85153d04a28d4bb05ab39a9b54c782c9efa54bd80ca961e2bcc6348a9b546 platform-source.patch
|
||||
f3d1feabe5124f882306b1b58485a86ac2072d93981d468dc746efdf4ed6b6b3 reproduce-baseline.py
|
||||
28f077918a87437d76b14dc93a035b358a99f1a721735b189263cf6d3b27fdea run-tests.sh
|
||||
2102b703320a633de8a5135a753020d4e8e0854947df41f400b2935562f38191 tests.log
|
||||
88e329cb244818427717b9f28b26b2ef2610c20f6ad3dfd3ae0d83386b6c1b51 workplan-source-draft.md
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
Baseline Platform 104ed90f with candidate regressions; expected two failures.
|
||||
test_acknowledged_11_still_requires_its_exact_parameters (test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters) ... FAIL
|
||||
test_keycape_generator_actor_matches_applied_issuer_binding (test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding) ... FAIL
|
||||
|
||||
======================================================================
|
||||
FAIL: test_acknowledged_11_still_requires_its_exact_parameters (test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/platform-broker-r1/railiance-platform/tests/test_keycape_client_secret_generate.py", line 188, in test_acknowledged_11_still_requires_its_exact_parameters
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
|
||||
AssertionError: Tuples differ: (0, 'generated') != (2, 'case_source_validation_failed')
|
||||
|
||||
First differing element 0:
|
||||
0
|
||||
2
|
||||
|
||||
- (0, 'generated')
|
||||
+ (2, 'case_source_validation_failed')
|
||||
|
||||
======================================================================
|
||||
FAIL: test_keycape_generator_actor_matches_applied_issuer_binding (test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/platform-broker-r1/railiance-platform/tests/test_credential_grants.py", line 36, in test_keycape_generator_actor_matches_applied_issuer_binding
|
||||
self.assertEqual(grant['actors']['allowed_types'], ['human-operator'])
|
||||
AssertionError: Lists differ: ['approved-agent', 'human-operator'] != ['human-operator']
|
||||
|
||||
First differing element 0:
|
||||
'approved-agent'
|
||||
'human-operator'
|
||||
|
||||
First list contains 1 additional elements.
|
||||
First extra element 1:
|
||||
'human-operator'
|
||||
|
||||
- ['approved-agent', 'human-operator']
|
||||
+ ['human-operator']
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 2 tests in 0.381s
|
||||
|
||||
FAILED (failures=2)
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
version: 1
|
||||
updated: "2026-08-13"
|
||||
owner_repo: railiance-platform
|
||||
owner_domain: financials
|
||||
workplan_id: RAILIANCE-WP-0005
|
||||
state_hub_workstream_id: 2731fece-6c49-45b8-ab8a-4ea6c04ac603
|
||||
|
||||
delivery_modes:
|
||||
allowed_known:
|
||||
- exec-env
|
||||
- response-wrap
|
||||
- local-token-file
|
||||
- kubernetes-auth
|
||||
denied_known:
|
||||
- chat
|
||||
- state-hub-body
|
||||
- git
|
||||
- command-line-token-argument
|
||||
- llm-prompt
|
||||
|
||||
grant_classes:
|
||||
- self-service
|
||||
- approval-required
|
||||
- break-glass
|
||||
|
||||
grants:
|
||||
- id: rapp-postgres/audit-core-runtime
|
||||
title: audit-core runtime PostgreSQL lease
|
||||
rapp_id: rapp-postgres
|
||||
status: active
|
||||
grant_class: self-service
|
||||
credential_type: openbao-database-credential
|
||||
issuer: openbao
|
||||
audience: audit-core
|
||||
description: Short-lived runtime connection to the audit-core database.
|
||||
openbao:
|
||||
namespace: openbao
|
||||
token_role: audit-core-runtime-broker
|
||||
issuer_policy: credential-broker-audit-core-runtime-issuer
|
||||
policies: [credential-broker-audit-core-runtime]
|
||||
disallowed_policies: [root, platform-admin]
|
||||
mount_paths: [database/creds/audit-core-runtime]
|
||||
database:
|
||||
role: audit-core-runtime
|
||||
host: platform-pg-rw.databases.svc.cluster.local
|
||||
port: 5432
|
||||
name: audit_core
|
||||
env: {PGUSER: username, PGPASSWORD: password, PGHOST: host, PGPORT: port, PGDATABASE: database}
|
||||
ttl: {default: 15m, max: 1h, renewable: false, requires_human_above: 1h}
|
||||
actors:
|
||||
allowed_types: [human-operator, approved-agent, ci-runner]
|
||||
required_subject_binding: keycape-or-kubernetes-service-account
|
||||
authorization:
|
||||
flex_auth_required: false
|
||||
flex_auth_mode: optional-preflight
|
||||
approval_required: false
|
||||
purpose_required: true
|
||||
allowed_purpose_examples: [audit-core-runtime]
|
||||
delivery:
|
||||
allowed: [exec-env]
|
||||
preferred: exec-env
|
||||
denied: [chat, state-hub-body, git, command-line-token-argument, llm-prompt]
|
||||
exec_env: {child_only: true, redact_logs: true}
|
||||
audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false}
|
||||
revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false}
|
||||
|
||||
- id: rapp-postgres/audit-core-migration
|
||||
title: audit-core migration PostgreSQL lease
|
||||
rapp_id: rapp-postgres
|
||||
status: active
|
||||
grant_class: self-service
|
||||
credential_type: openbao-database-credential
|
||||
issuer: openbao
|
||||
audience: audit-core
|
||||
description: Short-lived migration connection to the audit-core database.
|
||||
openbao:
|
||||
namespace: openbao
|
||||
token_role: audit-core-migration-broker
|
||||
issuer_policy: credential-broker-audit-core-migration-issuer
|
||||
policies: [credential-broker-audit-core-migration]
|
||||
disallowed_policies: [root, platform-admin]
|
||||
mount_paths: [database/creds/audit-core-migration]
|
||||
database:
|
||||
role: audit-core-migration
|
||||
host: platform-pg-rw.databases.svc.cluster.local
|
||||
port: 5432
|
||||
name: audit_core
|
||||
env: {PGUSER: username, PGPASSWORD: password, PGHOST: host, PGPORT: port, PGDATABASE: database}
|
||||
ttl: {default: 10m, max: 30m, renewable: false, requires_human_above: 30m}
|
||||
actors:
|
||||
allowed_types: [human-operator, approved-agent, ci-runner]
|
||||
required_subject_binding: keycape-or-kubernetes-service-account
|
||||
authorization:
|
||||
flex_auth_required: false
|
||||
flex_auth_mode: optional-preflight
|
||||
approval_required: false
|
||||
purpose_required: true
|
||||
allowed_purpose_examples: [audit-core-migration]
|
||||
delivery:
|
||||
allowed: [exec-env]
|
||||
preferred: exec-env
|
||||
denied: [chat, state-hub-body, git, command-line-token-argument, llm-prompt]
|
||||
exec_env: {child_only: true, redact_logs: true}
|
||||
audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false}
|
||||
revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false}
|
||||
|
||||
- id: keycape/client-secret-generate
|
||||
title: KeyCape confidential-client secret generation in custody
|
||||
status: pilot
|
||||
grant_class: approval-required
|
||||
credential_type: openbao-token
|
||||
issuer: openbao
|
||||
audience: railiance-platform-credential-broker
|
||||
description: >
|
||||
RPF-WP-0054 / CCR-2026-0040. Short-lived token the broker uses inside its
|
||||
own process to create platform/workloads/keycape/<client_id> once (CAS 0)
|
||||
for an accepted oidc-browser-relying-party case. Create-only: it cannot
|
||||
update, read values, write metadata, list or delete. The generated value
|
||||
never leaves the broker process and the bao child's stdin; the caller gets a
|
||||
receipt only. Founder decision 2026-10-09 (RPF-WP-0054-T02 option A).
|
||||
openbao:
|
||||
namespace: openbao
|
||||
token_role: keycape-client-secret-generate
|
||||
issuer_policy: credential-broker-keycape-client-secret-generate-issuer
|
||||
policies:
|
||||
- keycape-client-secret-generate
|
||||
disallowed_policies:
|
||||
- root
|
||||
- platform-admin
|
||||
mount_paths:
|
||||
- platform/data/workloads/keycape/+
|
||||
- platform/metadata/workloads/keycape/+
|
||||
ttl:
|
||||
default: 5m
|
||||
max: 5m
|
||||
renewable: false
|
||||
requires_human_above: 5m
|
||||
actors:
|
||||
allowed_types:
|
||||
- human-operator
|
||||
required_subject_binding: auth/netkingdom/role/credential-broker-keycape-client-secret-generate
|
||||
# CCR-2026-0043: human net-kingdom-admins membership and /assurance/level=aal2.
|
||||
authorization:
|
||||
flex_auth_required: false
|
||||
flex_auth_mode: optional-preflight
|
||||
approval_required: true
|
||||
purpose_required: true
|
||||
allowed_purpose_examples:
|
||||
- oidc-browser-relying-party-mint-secret
|
||||
delivery:
|
||||
allowed:
|
||||
- exec-env
|
||||
preferred: exec-env
|
||||
denied:
|
||||
- chat
|
||||
- state-hub-body
|
||||
- git
|
||||
- command-line-token-argument
|
||||
- llm-prompt
|
||||
exec_env:
|
||||
variable: VAULT_TOKEN
|
||||
child_only: true
|
||||
redact_logs: true
|
||||
audit:
|
||||
openbao_audit_required: true
|
||||
state_hub_metadata_allowed: true
|
||||
record_secret_values: false
|
||||
metadata_fields:
|
||||
- grant_id
|
||||
- actor
|
||||
- subject
|
||||
- purpose
|
||||
- case_ref
|
||||
- client_id
|
||||
- kv_version
|
||||
- lease_accessor
|
||||
- status
|
||||
revocation:
|
||||
required: true
|
||||
by_accessor: true
|
||||
on_exec_exit: true
|
||||
on_denied_request: false
|
||||
- id: ops-warden/warden-sign
|
||||
title: Ops Warden OpenBao SSH signing smoke token
|
||||
status: pilot
|
||||
grant_class: self-service
|
||||
credential_type: openbao-token
|
||||
issuer: openbao
|
||||
audience: ops-warden
|
||||
description: >
|
||||
Short-lived OpenBao child token for ops-warden SSH signing smoke tests.
|
||||
The token may only use the warden-sign policy and must not be treated as
|
||||
an ops-warden-owned secret.
|
||||
openbao:
|
||||
namespace: openbao
|
||||
token_role: warden-sign
|
||||
issuer_policy: credential-broker-warden-sign-issuer
|
||||
policies:
|
||||
- warden-sign
|
||||
disallowed_policies:
|
||||
- root
|
||||
- platform-admin
|
||||
mount_paths:
|
||||
- ssh/sign/adm-role
|
||||
- ssh/sign/agt-role
|
||||
- ssh/sign/atm-role
|
||||
- ssh/roles
|
||||
ttl:
|
||||
default: 15m
|
||||
max: 1h
|
||||
renewable: false
|
||||
requires_human_above: 1h
|
||||
actors:
|
||||
allowed_types:
|
||||
- human-operator
|
||||
- approved-agent
|
||||
- ci-runner
|
||||
required_subject_binding: keycape-or-kubernetes-service-account
|
||||
authorization:
|
||||
flex_auth_required: false
|
||||
flex_auth_mode: optional-preflight
|
||||
approval_required: false
|
||||
purpose_required: true
|
||||
allowed_purpose_examples:
|
||||
- flex-auth-openbao-smoke
|
||||
- ops-warden-production-sign-smoke
|
||||
delivery:
|
||||
allowed:
|
||||
- exec-env
|
||||
- response-wrap
|
||||
- local-token-file
|
||||
- kubernetes-auth
|
||||
preferred: exec-env
|
||||
denied:
|
||||
- chat
|
||||
- state-hub-body
|
||||
- git
|
||||
- command-line-token-argument
|
||||
- llm-prompt
|
||||
exec_env:
|
||||
variable: VAULT_TOKEN
|
||||
child_only: true
|
||||
redact_logs: true
|
||||
response_wrap:
|
||||
ttl: 5m
|
||||
unwrap_once: true
|
||||
local_token_file:
|
||||
directory: .local/credential-leases
|
||||
mode: "0600"
|
||||
kubernetes_auth:
|
||||
mount: auth/kubernetes
|
||||
role: credential-broker-warden-sign
|
||||
audience: openbao
|
||||
service_account_names:
|
||||
- credential-broker
|
||||
- ops-warden-smoke
|
||||
namespaces:
|
||||
- openbao
|
||||
- ops-warden
|
||||
audit:
|
||||
openbao_audit_required: true
|
||||
state_hub_metadata_allowed: true
|
||||
record_secret_values: false
|
||||
metadata_fields:
|
||||
- grant_id
|
||||
- actor
|
||||
- subject
|
||||
- purpose
|
||||
- requested_ttl
|
||||
- issued_ttl
|
||||
- delivery_mode
|
||||
- lease_accessor
|
||||
- decision_id
|
||||
- status
|
||||
revocation:
|
||||
required: true
|
||||
by_accessor: true
|
||||
on_exec_exit: true
|
||||
on_denied_request: false
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env python3
|
||||
"""RPF-WP-0054-T03: generate a KeyCape confidential-client secret inside custody.
|
||||
|
||||
Entry point for the oidc-browser-relying-party ``mint-secret`` step (lane
|
||||
``keycape-client-secret-generate``). Normally reached as
|
||||
``credential.py generate ...``; ops-warden runs it as a child in the caller's
|
||||
own environment and holds no token.
|
||||
|
||||
credential.py generate --grant keycape/client-secret-generate \\
|
||||
--client-id forgejo --case-file <case.yaml> \\
|
||||
--case-ref forgejo-netkingdom-oidc@sha256:<hex64> --receipt <file>
|
||||
|
||||
Steps (every one fails closed):
|
||||
|
||||
1. Refuse agents (``WARDEN_AGENT_ID``) and root. The caller's session must hold
|
||||
the broker issuer policy for this grant.
|
||||
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
|
||||
bytes) and require: it equals ``--case-ref``; type
|
||||
an exactly source-pinned acknowledged browser revision; status ``materialized``; client_type
|
||||
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
|
||||
matches the type's pattern and is not a reserved entry.
|
||||
3. Mint a 5-minute grant token through token role
|
||||
``keycape-client-secret-generate``. All KV calls below use only that token.
|
||||
4. Prove absence by metadata (404). An existing entry is never touched.
|
||||
5. Generate the value in this process and write it with CAS 0 through the
|
||||
``bao`` child's stdin.
|
||||
6. Verify by metadata only: current version 1, live. The data path is never read.
|
||||
7. Revoke the grant token (revoke-self).
|
||||
|
||||
The process prints nothing. The receipt (mode 0600, refused if it exists) and
|
||||
the optional State Hub event hold the path, version, case reference, booleans and
|
||||
an outcome code only.
|
||||
|
||||
Exit codes: 0 generated (dry-run: would generate); 1 contained internal error;
|
||||
2 precondition refused; 3 entry already exists (never overwritten, including a
|
||||
lost CAS race); 4 token or write failure; 5 verification failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
|
||||
GRANT = 'keycape/client-secret-generate'
|
||||
TOKEN_ROLE = 'keycape-client-secret-generate'
|
||||
GRANT_POLICY = 'keycape-client-secret-generate'
|
||||
ISSUER_POLICY = 'credential-broker-keycape-client-secret-generate-issuer'
|
||||
PREFIX = 'workloads/keycape'
|
||||
RESERVED = {'secrets-engine-openbao'}
|
||||
FIELD = 'CLIENT_SECRET'
|
||||
# Owner configuration, never a caller-supplied root or a version search/fallback.
|
||||
# The sibling source checkout must contain these exact reviewed bytes.
|
||||
NETKINGDOM_ROOT = Path(__file__).resolve().parents[2] / 'net-kingdom'
|
||||
ACKNOWLEDGED_TYPES = {
|
||||
'oidc-browser-relying-party@1.1': (
|
||||
'security-case-types/oidc-browser-relying-party/type.yaml',
|
||||
'f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c'),
|
||||
}
|
||||
# Same authenticated producer and parameter projection as ops-warden case_inputs
|
||||
# at b593c879, restricted to this broker's currently admitted 1.1. Source-only
|
||||
# acknowledgment (including 1.2) never expands broker admission. A future 1.3
|
||||
# entry requires real acknowledgment and a separately reviewed admission binding.
|
||||
# Neither source status nor caller-supplied YAML expands this set.
|
||||
SOURCE_PINS = {
|
||||
'tools/security-case-types/security_case_validator.py':
|
||||
'1bcc62e9bc374c711363295549f3cd5bb0b32952671ed4b08fddc7f74742959b',
|
||||
'tools/security-scenario-composer/security_scenario_composer.py':
|
||||
'9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f',
|
||||
'tools/playbook-capability-contract/playbook_contract_validator.py':
|
||||
'2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d',
|
||||
}
|
||||
PARAMETER_PINS = {
|
||||
'1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c',
|
||||
}
|
||||
_VALIDATE = """
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(sys.argv[1]) / 'tools/security-case-types'))
|
||||
import security_case_validator as v
|
||||
case, typ = json.load(sys.stdin)
|
||||
issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False)
|
||||
print('valid' if not v.has_errors(issues) else 'invalid')
|
||||
"""
|
||||
# oidc-browser-relying-party@1.1 parameter client_id.
|
||||
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
|
||||
# 48 random bytes -> 64 URL-safe characters, confirmed by key-cape 2026-10-10
|
||||
# (message 4e0213b3; opaque constant-time comparison, no alphabet limit).
|
||||
SECRET_BYTES = 48
|
||||
TOKEN_TTL = '5m'
|
||||
SCHEMA = 'platform.keycape-client-secret-generate.v1'
|
||||
|
||||
|
||||
class Exit(Exception):
|
||||
def __init__(self, code: int, reason: str):
|
||||
super().__init__(reason)
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def bao(args, token=None, payload=None):
|
||||
env = dict(os.environ)
|
||||
if token is not None:
|
||||
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
|
||||
return subprocess.run(['bao', *args], env=env,
|
||||
input=None if payload is None else json.dumps(payload).encode(),
|
||||
capture_output=True, timeout=60)
|
||||
|
||||
|
||||
def absent(result):
|
||||
return result.returncode != 0 and (b'404' in result.stderr or b'No value found' in result.stderr)
|
||||
|
||||
|
||||
def read_json(result, code):
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
raise Exit(code, 'unparseable_bao_output') from None
|
||||
|
||||
|
||||
def authenticated_source(relative, expected):
|
||||
path = NETKINGDOM_ROOT / relative
|
||||
# Refuse symlinks at every selected component, including the configured root.
|
||||
if any(part.is_symlink() for part in (path, *path.parents)):
|
||||
raise ValueError
|
||||
raw = path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != expected:
|
||||
raise ValueError
|
||||
return raw
|
||||
|
||||
|
||||
def check_acknowledged_case(doc):
|
||||
# Authenticates the selected TYPE/producer bytes and validates case schema.
|
||||
# The caller's self-hashed case reference is not authenticated case admission
|
||||
# or owner acceptance. Those require independent Warden/native evidence;
|
||||
# this source-only hardening does not complete the real admission binding.
|
||||
binding = ACKNOWLEDGED_TYPES.get(doc['spec'].get('type'))
|
||||
if binding is None:
|
||||
raise Exit(2, 'case_type_unacknowledged')
|
||||
try:
|
||||
typ = yaml.safe_load(authenticated_source(*binding))
|
||||
spec = typ['spec']
|
||||
version = str(spec['version'])
|
||||
fingerprint = hashlib.sha256(json.dumps(
|
||||
spec['parameters'], sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
if (spec['status'] not in ('acknowledged', 'implemented')
|
||||
or f"{spec['id']}@{version}" != doc['spec']['type']
|
||||
or PARAMETER_PINS.get(version) != fingerprint):
|
||||
raise ValueError
|
||||
with tempfile.TemporaryDirectory(prefix='platform-case-validator-') as directory:
|
||||
for relative, expected in SOURCE_PINS.items():
|
||||
raw = authenticated_source(relative, expected)
|
||||
dest = Path(directory) / relative
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(raw)
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-I', '-c', _VALIDATE, directory],
|
||||
input=json.dumps([doc, typ]), text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
env={}, timeout=10, check=True)
|
||||
if result.stdout != 'valid\n':
|
||||
raise ValueError
|
||||
except (OSError, ValueError, TypeError, KeyError, yaml.YAMLError,
|
||||
subprocess.SubprocessError):
|
||||
raise Exit(2, 'case_source_validation_failed') from None
|
||||
|
||||
|
||||
def check_case(args, receipt):
|
||||
if not CLIENT_ID_RE.match(args.client_id) or args.client_id in RESERVED:
|
||||
raise Exit(2, 'client_id_invalid_or_reserved')
|
||||
if not CASE_REF_RE.match(args.case_ref):
|
||||
raise Exit(2, 'case_ref_malformed')
|
||||
try:
|
||||
raw = Path(args.case_file).read_bytes()
|
||||
doc = yaml.safe_load(raw)
|
||||
except (OSError, yaml.YAMLError):
|
||||
raise Exit(2, 'case_file_unreadable') from None
|
||||
spec = (doc or {}).get('spec') if isinstance(doc, dict) else None
|
||||
if not isinstance(spec, dict):
|
||||
raise Exit(2, 'case_file_shape')
|
||||
params = spec.get('parameters') or {}
|
||||
derived = f"{spec.get('id')}@sha256:{hashlib.sha256(raw).hexdigest()}"
|
||||
if derived != args.case_ref:
|
||||
raise Exit(2, 'case_ref_mismatch')
|
||||
if not isinstance(spec.get('type'), str):
|
||||
raise Exit(2, 'case_type_unacknowledged')
|
||||
if spec.get('status') != 'materialized':
|
||||
raise Exit(2, 'case_not_materialized')
|
||||
if params.get('client_type') != 'confidential':
|
||||
raise Exit(2, 'case_client_not_confidential')
|
||||
if params.get('client_id') != args.client_id:
|
||||
raise Exit(2, 'case_client_id_mismatch')
|
||||
check_acknowledged_case(doc)
|
||||
receipt['case_verified'] = True
|
||||
|
||||
|
||||
def run(args, receipt, state):
|
||||
if args.grant != GRANT:
|
||||
raise Exit(2, 'unknown_grant')
|
||||
if os.environ.get('WARDEN_AGENT_ID'):
|
||||
raise Exit(2, 'agent_callers_refused')
|
||||
check_case(args, receipt)
|
||||
|
||||
ident = bao(['token', 'lookup', '-format=json'])
|
||||
if ident.returncode != 0:
|
||||
raise Exit(2, 'caller_session_missing')
|
||||
policies = (read_json(ident, 2).get('data') or {}).get('policies') or []
|
||||
if 'root' in policies:
|
||||
raise Exit(2, 'root_refused')
|
||||
if ISSUER_POLICY not in policies:
|
||||
raise Exit(2, 'caller_lacks_issuer_policy')
|
||||
|
||||
created = bao(['token', 'create', f'-role={TOKEN_ROLE}', f'-policy={GRANT_POLICY}',
|
||||
f'-ttl={TOKEN_TTL}', '-format=json'])
|
||||
if created.returncode != 0:
|
||||
raise Exit(4, 'grant_token_create_failed')
|
||||
try:
|
||||
state['token'] = read_json(created, 4)['auth']['client_token']
|
||||
except (KeyError, TypeError):
|
||||
raise Exit(4, 'grant_token_create_failed') from None
|
||||
token = state['token']
|
||||
|
||||
meta_path = f'platform/metadata/{PREFIX}/{args.client_id}'
|
||||
meta = bao(['read', '-format=json', meta_path], token=token)
|
||||
if meta.returncode == 0:
|
||||
raise Exit(3, 'already_present')
|
||||
if not absent(meta):
|
||||
raise Exit(2, 'absence_not_proven')
|
||||
if args.dry_run:
|
||||
return 'dry_run_would_generate'
|
||||
|
||||
value = secrets.token_urlsafe(SECRET_BYTES)
|
||||
written = bao(['write', '-format=json', f'platform/data/{PREFIX}/{args.client_id}', '-'],
|
||||
token=token, payload={'options': {'cas': 0}, 'data': {FIELD: value}})
|
||||
del value
|
||||
if written.returncode != 0:
|
||||
if bao(['read', '-format=json', meta_path], token=token).returncode == 0:
|
||||
raise Exit(3, 'already_present_lost_race')
|
||||
raise Exit(4, 'write_failed')
|
||||
|
||||
meta = bao(['read', '-format=json', meta_path], token=token)
|
||||
if meta.returncode != 0:
|
||||
raise Exit(5, 'verify_metadata_unreadable')
|
||||
data = read_json(meta, 5).get('data') or {}
|
||||
version = (data.get('versions') or {}).get('1') or {}
|
||||
if data.get('current_version') != 1 or version.get('deletion_time') or version.get('destroyed'):
|
||||
raise Exit(5, 'verify_version_mismatch')
|
||||
receipt['kv_version'] = 1
|
||||
return 'generated'
|
||||
|
||||
|
||||
def record_state_hub(url, receipt):
|
||||
body = {
|
||||
'summary': (f"keycape-client-secret-generate {receipt['outcome']}: {receipt['path']} "
|
||||
f"v{receipt.get('kv_version', '-')} case {receipt['case_ref']}"),
|
||||
'event_type': 'credential_change_evidence',
|
||||
'author': 'railiance-platform-credential-broker',
|
||||
}
|
||||
request = urllib.request.Request(url.rstrip('/') + '/progress/', data=json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json'}, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return 200 <= response.status < 300
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 1)
|
||||
os.dup2(devnull, 2)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--grant', required=True)
|
||||
parser.add_argument('--client-id', required=True)
|
||||
parser.add_argument('--case-ref', required=True)
|
||||
parser.add_argument('--case-file', required=True)
|
||||
parser.add_argument('--receipt', type=Path)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--record-state-hub', action='store_true')
|
||||
parser.add_argument('--state-hub-url', default=os.environ.get('STATE_HUB_URL', 'http://127.0.0.1:8000'))
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit:
|
||||
return 2
|
||||
receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'generate',
|
||||
'grant': args.grant, 'client_id': args.client_id, 'case_ref': args.case_ref,
|
||||
'path': f'platform/{PREFIX}/{args.client_id}', 'value_reads': 0,
|
||||
'credential_values_emitted': False}
|
||||
fd = None
|
||||
if args.receipt is not None:
|
||||
try:
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
except OSError:
|
||||
return 2
|
||||
state = {}
|
||||
try:
|
||||
receipt['outcome'] = run(args, receipt, state)
|
||||
code = 0
|
||||
except Exit as exc:
|
||||
receipt['outcome'] = exc.reason
|
||||
code = exc.code
|
||||
except Exception:
|
||||
receipt['outcome'] = 'contained_internal_error'
|
||||
code = 1
|
||||
if state.get('token'):
|
||||
receipt['grant_token_revoked'] = bao(['token', 'revoke', '-self'], token=state['token']).returncode == 0
|
||||
receipt['exit_code'] = code
|
||||
if args.record_state_hub:
|
||||
try:
|
||||
receipt['state_hub_recorded'] = record_state_hub(args.state_hub_url, receipt)
|
||||
except Exception:
|
||||
receipt['state_hub_recorded'] = False
|
||||
if fd is not None:
|
||||
os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode())
|
||||
os.close(fd)
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"credential_grants_validate", REPO_DIR / "scripts/credential-grants-validate.py"
|
||||
)
|
||||
grants = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = grants
|
||||
SPEC.loader.exec_module(grants)
|
||||
|
||||
|
||||
class CredentialGrantBindTests(unittest.TestCase):
|
||||
def test_catalog_validates(self) -> None:
|
||||
errors: list[str] = []
|
||||
catalog = grants.yaml.safe_load(
|
||||
(REPO_DIR / "credential-grants/catalog.yaml").read_text()
|
||||
)
|
||||
for index, grant in enumerate(catalog["grants"]):
|
||||
grants.validate_grant(grant, index, catalog, errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_keycape_generator_actor_matches_applied_issuer_binding(self) -> None:
|
||||
catalog = grants.yaml.safe_load(
|
||||
(REPO_DIR / 'credential-grants/catalog.yaml').read_text())
|
||||
grant = next(g for g in catalog['grants'] if g['id'] == 'keycape/client-secret-generate')
|
||||
ccr = grants.yaml.safe_load((REPO_DIR / 'credential-change-requests' /
|
||||
'CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml').read_text())
|
||||
role = grants.yaml.safe_load((REPO_DIR / ccr['openbao']['auth']['role_file']).read_text())
|
||||
auth = ccr['openbao']['auth']
|
||||
self.assertEqual(grant['actors']['allowed_types'], ['human-operator'])
|
||||
self.assertEqual(grant['actors']['required_subject_binding'],
|
||||
f"auth/{auth['mount']}/role/{auth['role']}")
|
||||
self.assertEqual(role['bound_claims'],
|
||||
{'groups': ['net-kingdom-admins'], '/assurance/level': 'aal2'})
|
||||
|
||||
def test_postgres_grants_bind_rapp(self) -> None:
|
||||
catalog = grants.yaml.safe_load(
|
||||
(REPO_DIR / "credential-grants/catalog.yaml").read_text()
|
||||
)
|
||||
bound = {
|
||||
grant["id"]: grant.get("rapp_id")
|
||||
for grant in catalog["grants"]
|
||||
if str(grant["id"]).startswith("rapp-")
|
||||
}
|
||||
self.assertEqual(
|
||||
bound,
|
||||
{
|
||||
"rapp-postgres/audit-core-runtime": "rapp-postgres",
|
||||
"rapp-postgres/audit-core-migration": "rapp-postgres",
|
||||
},
|
||||
)
|
||||
|
||||
def test_rapp_prefix_requires_matching_rapp_id(self) -> None:
|
||||
errors: list[str] = []
|
||||
grant = {
|
||||
"id": "rapp-postgres/audit-core-runtime",
|
||||
"title": "x",
|
||||
"description": "x",
|
||||
"status": "active",
|
||||
"grant_class": "self-service",
|
||||
"credential_type": "openbao-database-credential",
|
||||
"issuer": "openbao",
|
||||
"audience": "audit-core",
|
||||
"openbao": {
|
||||
"token_role": "audit-core-runtime-broker",
|
||||
"issuer_policy": "credential-broker-audit-core-runtime-issuer",
|
||||
"policies": ["credential-broker-audit-core-runtime"],
|
||||
"disallowed_policies": ["root", "platform-admin"],
|
||||
"mount_paths": ["database/creds/audit-core-runtime"],
|
||||
},
|
||||
"ttl": {"default": "15m", "max": "1h", "renewable": False},
|
||||
"actors": {"allowed_types": ["human-operator"]},
|
||||
"authorization": {
|
||||
"flex_auth_required": False,
|
||||
"purpose_required": True,
|
||||
},
|
||||
"delivery": {
|
||||
"allowed": ["exec-env"],
|
||||
"preferred": "exec-env",
|
||||
"denied": [
|
||||
"chat",
|
||||
"state-hub-body",
|
||||
"git",
|
||||
"command-line-token-argument",
|
||||
"llm-prompt",
|
||||
],
|
||||
},
|
||||
"audit": {
|
||||
"openbao_audit_required": True,
|
||||
"record_secret_values": False,
|
||||
},
|
||||
"revocation": {"required": True, "by_accessor": True},
|
||||
}
|
||||
grants.validate_grant(grant, 0, {}, errors)
|
||||
self.assertTrue(any("rapp_id is required" in error for error in errors))
|
||||
errors.clear()
|
||||
grant["rapp_id"] = "rapp-openbao"
|
||||
grants.validate_grant(grant, 0, {}, errors)
|
||||
self.assertTrue(any("must equal the grant id prefix" in error for error in errors))
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
"""Offline tests for credential.py generate (RPF-WP-0054-T03) against a fake ``bao``.
|
||||
|
||||
Runs ``credential.py generate`` as a real subprocess, so the silence contract and
|
||||
the delegation from credential.py are tested end to end. The fake records which
|
||||
token each call used: KV calls must use the minted grant token, never the
|
||||
caller's session.
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import shutil
|
||||
from unittest import mock
|
||||
|
||||
import yaml
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CLI = ROOT / 'scripts/credential.py'
|
||||
GRANT_TOKEN = 'hvs.grant-' + 'G7h8' * 6
|
||||
|
||||
FAKE_BAO = textwrap.dedent(f'''\
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
sf = os.environ['FAKE_BAO_STATE']
|
||||
st = json.load(open(sf))
|
||||
a = sys.argv[1:]
|
||||
tok = os.environ.get('BAO_TOKEN', 'caller-session')
|
||||
st['calls'].append({{'args': a, 'token': tok}})
|
||||
def save(): json.dump(st, open(sf, 'w'))
|
||||
def out(obj): save(); print(json.dumps(obj)); sys.exit(0)
|
||||
def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2)
|
||||
if a[:2] == ['token', 'lookup']: out({{'data': {{'policies': st['caller_policies']}}}})
|
||||
if a[:2] == ['token', 'create']:
|
||||
if st.get('fail_create'): save(); sys.exit(2)
|
||||
out({{'auth': {{'client_token': '{GRANT_TOKEN}', 'accessor': 'acc'}}}})
|
||||
if a[:2] == ['token', 'revoke']: st['revoked'] = tok; save(); sys.exit(0)
|
||||
if a[0] == 'read':
|
||||
rest = a[-1].split('/', 2)[2]
|
||||
if rest not in st['kv']: missing()
|
||||
n = len(st['kv'][rest])
|
||||
out({{'data': {{'current_version': n, 'versions': {{str(i + 1): {{}} for i in range(n)}}}}}})
|
||||
if a[0] == 'write':
|
||||
rest = a[-2].split('/', 2)[2]
|
||||
body = json.loads(sys.stdin.read())
|
||||
st['written'] = body
|
||||
if st.get('race'): st['kv'][rest] = [{{'x': 1}}]; save(); sys.exit(2)
|
||||
if st.get('fail_write'): save(); sys.exit(2)
|
||||
e = st['kv'].setdefault(rest, [])
|
||||
if body['options']['cas'] != len(e): save(); sys.exit(2)
|
||||
e.append(body['data'])
|
||||
out({{'data': {{'version': len(e)}}}})
|
||||
save(); sys.exit(9)
|
||||
''')
|
||||
|
||||
# Exact accepted Forgejo @1.1 shape; materialized only in this fake test.
|
||||
CASE = '# First KnownSecurityCase of oidc-browser-relying-party 1.1 (NK-WP-0048-T06).\n# Parameters are the request the founder accepted on 2026-10-08\n# (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01); that\n# acceptance carries over as this case\'s acceptance. One difference: custody is\n# derived by the type (platform/workloads/keycape/forgejo), not the request\'s\n# proposed platform/workloads/forgejo/netkingdom-oidc-client. The founder\n# acknowledged that derivation with the type (memo r4, 2026-10-09).\n# Re-pinned from 1.0 to 1.1 on 2026-10-10 on the founder\'s go, before any\n# apply, so no 1.0 run overlaps (minor change: consumer-enforces-groups).\napiVersion: netkingdom.io/security-case/v0.1\nkind: SecurityCase\nspec:\n id: forgejo-netkingdom-oidc\n type: oidc-browser-relying-party@1.1\n status: materialized\n acceptance:\n at: "2026-10-08"\n record: "key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e"\n by:\n - {role: owner, name: "founder (for railiance-apps)"}\n - {role: founder, name: founder}\n owner:\n repo: railiance-apps\n person: founder\n parameters:\n client_id: forgejo\n display_name: Forgejo source forge\n redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback\n allowed_scopes: openid,profile,email,groups\n client_type: confidential\n mfa_required: "true"\n access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers\n initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick\n consuming_service: forgejo\n consumer_owner_repo: railiance-apps\n'
|
||||
|
||||
|
||||
class Generate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
(self.tmp / 'bin').mkdir()
|
||||
(self.tmp / 'bin/bao').write_text(FAKE_BAO)
|
||||
(self.tmp / 'bin/bao').chmod(0o755)
|
||||
self.state = {'calls': [], 'kv': {},
|
||||
'caller_policies': ['default', 'credential-broker-keycape-client-secret-generate-issuer']}
|
||||
self.write_case(CASE)
|
||||
|
||||
def write_case(self, text):
|
||||
self.case = self.tmp / 'case.yaml'
|
||||
self.case.write_text(text)
|
||||
self.ref = 'forgejo-netkingdom-oidc@sha256:' + hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
def run_cli(self, *extra, client_id='forgejo', ref=None, env_extra=None):
|
||||
(self.tmp / 'state.json').write_text(json.dumps(self.state))
|
||||
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
|
||||
empty_home = self.tmp / 'empty-home'
|
||||
empty_home.mkdir(exist_ok=True)
|
||||
env = {'PATH': os.defpath, 'HOME': str(empty_home)}
|
||||
env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', FAKE_BAO_STATE=str(self.tmp / 'state.json'),
|
||||
**(env_extra or {}))
|
||||
proc = subprocess.run([sys.executable, str(CLI), 'generate', '--grant', 'keycape/client-secret-generate',
|
||||
'--client-id', client_id, '--case-file', str(self.case),
|
||||
'--case-ref', ref or self.ref, '--receipt', str(receipt), *extra],
|
||||
env=env, capture_output=True, text=True)
|
||||
self.assertEqual((proc.stdout, proc.stderr), ('', ''))
|
||||
self.state = json.loads((self.tmp / 'state.json').read_text())
|
||||
text = receipt.read_text()
|
||||
self.assertNotIn(GRANT_TOKEN, text)
|
||||
written = self.state.get('written')
|
||||
if written:
|
||||
self.assertNotIn(written['data']['CLIENT_SECRET'], text)
|
||||
return proc.returncode, json.loads(text)
|
||||
|
||||
def kv_calls(self):
|
||||
return [c for c in self.state['calls'] if c['args'][0] in ('read', 'write')]
|
||||
|
||||
def test_generates_once_with_grant_token_and_revokes_it(self):
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
|
||||
self.assertEqual(receipt['kv_version'], 1)
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
self.assertEqual(self.state['revoked'], GRANT_TOKEN)
|
||||
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
|
||||
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
|
||||
value = self.state['written']['data']['CLIENT_SECRET']
|
||||
self.assertEqual(len(value), 64)
|
||||
self.assertEqual(self.state['written']['options'], {'cas': 0})
|
||||
|
||||
def test_existing_entry_is_never_touched(self):
|
||||
self.state['kv']['workloads/keycape/forgejo'] = [{'CLIENT_SECRET': 'old'}]
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
|
||||
self.assertNotIn('written', self.state)
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
|
||||
def test_lost_cas_race_reports_already_present(self):
|
||||
self.state['race'] = True
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
code, receipt = self.run_cli('--dry-run')
|
||||
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
|
||||
self.assertNotIn('written', self.state)
|
||||
|
||||
def test_case_checks_refuse_before_any_bao_call(self):
|
||||
cases = [
|
||||
('case_ref_mismatch', {'ref': 'forgejo-netkingdom-oidc@sha256:' + '0' * 64}),
|
||||
('case_client_id_mismatch', {'client_id': 'gitea'}),
|
||||
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'}),
|
||||
('client_id_invalid_or_reserved', {'client_id': '../forgejo'}),
|
||||
('case_ref_malformed', {'ref': 'forgejo'}),
|
||||
]
|
||||
for outcome, kwargs in cases:
|
||||
with self.subTest(outcome=outcome, **kwargs):
|
||||
self.state['calls'] = []
|
||||
code, receipt = self.run_cli(**kwargs)
|
||||
self.assertEqual((code, receipt['outcome']), (2, outcome))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_unmaterialized_or_public_case_refused(self):
|
||||
for text, outcome in ((CASE.replace('materialized', 'accepted'), 'case_not_materialized'),
|
||||
(CASE.replace('confidential', 'public'), 'case_client_not_confidential')):
|
||||
with self.subTest(outcome=outcome):
|
||||
self.write_case(text)
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, outcome))
|
||||
|
||||
def test_only_exact_acknowledged_type_version_is_admitted(self):
|
||||
for type_ref in ('oidc-browser-relying-party@1.0',
|
||||
'oidc-browser-relying-party@1.2',
|
||||
'oidc-browser-relying-party@1.3',
|
||||
'oidc-browser-relying-party@99.0',
|
||||
'confidential-service-client@1.1'):
|
||||
with self.subTest(type_ref=type_ref):
|
||||
self.write_case(CASE.replace('oidc-browser-relying-party@1.1', type_ref))
|
||||
self.state['calls'] = []
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_source_acknowledgment_alone_does_not_admit_12(self):
|
||||
self.write_case(CASE.replace('oidc-browser-relying-party@1.1',
|
||||
'oidc-browser-relying-party@1.2')
|
||||
+ ' consumer_namespace: forgejo\n')
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_caller_environment_cannot_select_type_or_source_root(self):
|
||||
code, receipt = self.run_cli(env_extra={
|
||||
'NETKINGDOM_ROOT': '/nonexistent',
|
||||
'WARDEN_NETKINGDOM_ROOT': '/nonexistent',
|
||||
'PYTHONPATH': '/nonexistent',
|
||||
})
|
||||
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
|
||||
|
||||
def test_acknowledged_11_still_requires_its_exact_parameters(self):
|
||||
self.write_case(CASE + ' unexpected_target: forgejo\n')
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_future_revision_requires_explicit_owner_binding_and_acknowledgment(self):
|
||||
# Synthetic owner configuration exercises the extension mechanism only.
|
||||
# This is not the actual proposed 1.3 type, acknowledgment or parameter pin.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'future_fixture_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
|
||||
broker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(broker)
|
||||
source = self.tmp / 'synthetic-owner-root'
|
||||
source.mkdir()
|
||||
relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
|
||||
for rel in (relative, *broker.SOURCE_PINS):
|
||||
dest = source / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
|
||||
typ = source / relative
|
||||
original = typ.read_bytes()
|
||||
doc = yaml.safe_load(CASE)
|
||||
doc['spec']['type'] = 'oidc-browser-relying-party@1.3'
|
||||
typ.write_bytes(original.replace(b'version: "1.1"', b'version: "1.3"'))
|
||||
# YAML numeric spelling is owner-authored, never an inferred revision.
|
||||
type_doc = yaml.safe_load(original)
|
||||
type_doc['spec']['version'] = '1.3'
|
||||
typ.write_text(yaml.safe_dump(type_doc))
|
||||
binding = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
|
||||
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
|
||||
with self.assertRaises(broker.Exit):
|
||||
broker.check_acknowledged_case(doc) # no explicit binding
|
||||
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, binding):
|
||||
with self.assertRaises(broker.Exit):
|
||||
broker.check_acknowledged_case(doc) # no parameter admission
|
||||
with mock.patch.dict(broker.PARAMETER_PINS, {'1.3': broker.PARAMETER_PINS['1.1']}):
|
||||
broker.check_acknowledged_case(doc)
|
||||
type_doc['spec']['status'] = 'proposed'
|
||||
typ.write_text(yaml.safe_dump(type_doc))
|
||||
proposed = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
|
||||
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, proposed):
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
|
||||
def test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed(self):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'candidate_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
|
||||
broker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(broker)
|
||||
source = self.tmp / 'net-kingdom'
|
||||
source.mkdir()
|
||||
relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
|
||||
for rel in (relative, *broker.SOURCE_PINS):
|
||||
dest = source / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
|
||||
doc = yaml.safe_load(CASE)
|
||||
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
|
||||
broker.check_acknowledged_case(doc)
|
||||
producer = source / next(iter(broker.SOURCE_PINS))
|
||||
raw = producer.read_bytes()
|
||||
producer.write_bytes(raw + b'\n# drift\n')
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
producer.write_bytes(raw)
|
||||
typ = source / relative
|
||||
raw = typ.read_bytes()
|
||||
typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed'))
|
||||
# Even a hypothetical owner pin update cannot admit proposed status.
|
||||
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
|
||||
doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}):
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
typ.write_bytes(raw + b'\n# source drift\n')
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
typ.unlink()
|
||||
typ.symlink_to(broker.__file__)
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
for ref in ('oidc-browser-relying-party@1.3', 'oidc-browser-relying-party@99.0'):
|
||||
doc['spec']['type'] = ref
|
||||
with self.assertRaises(broker.Exit) as caught:
|
||||
broker.check_acknowledged_case(doc)
|
||||
self.assertEqual(caught.exception.reason, 'case_type_unacknowledged')
|
||||
|
||||
def test_exact_accepted_case_still_requires_materialization(self):
|
||||
self.write_case(CASE.replace('status: materialized', 'status: accepted'))
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_not_materialized'))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_agents_root_and_unbound_callers_refused(self):
|
||||
code, receipt = self.run_cli(env_extra={'WARDEN_AGENT_ID': 'codex'})
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'agent_callers_refused'))
|
||||
self.state['caller_policies'] = ['root']
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'root_refused'))
|
||||
self.state['caller_policies'] = ['default']
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'caller_lacks_issuer_policy'))
|
||||
self.assertFalse(any(c['args'][:2] == ['token', 'create'] for c in self.state['calls']))
|
||||
|
||||
def test_write_failure_is_reported_and_token_revoked(self):
|
||||
self.state['fail_write'] = True
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (4, 'write_failed'))
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"schema": "custodian.platform-broker-candidate.v1",
|
||||
"candidate_only": true,
|
||||
"source_commits": {
|
||||
"railiance-platform": "104ed90f42a9a86bf9fda343b49b65a85c9ee4b9",
|
||||
"net-kingdom": "befb0bc68cd25b625804fce559476eefd702521d",
|
||||
"ops-warden": "b593c879831378db7dc62adfabdfedf2d44ddd38"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "scripts/keycape_client_secret_generate.py",
|
||||
"input_sha256": "9576cd211cfc06526ec4cc9bd0a5b06f0f59287a68931bb2d6e3592a951ddc7d",
|
||||
"output_sha256": "da80947a2c28df36b37e711d1e6d2f66b4c0434082e7bf187da3d3cb692cec28"
|
||||
},
|
||||
{
|
||||
"path": "credential-grants/catalog.yaml",
|
||||
"input_sha256": "cb70165f80c58245d4231b4e712a4b322c51a6b168513dc839ad4c0234027047",
|
||||
"output_sha256": "7cfe12970a57ab75cb91c7c5024a4dcfc28ffae4b31a1afaf4d0cedabbb22886"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_keycape_client_secret_generate.py",
|
||||
"input_sha256": "86063ef86fb8f43203659752895906838dc572f22512336464da9c74306c09d0",
|
||||
"output_sha256": "5906e32c7fe0db09c1f7db48ad61226497e062f38ebfa91e8a03be89272765c0"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_credential_grants.py",
|
||||
"input_sha256": "aec72c194df50fb37ba6a0ccc28685b041441f2bafd2c174a1a91eb0ae7220cc",
|
||||
"output_sha256": "5c0cfcb039c0220e59dac7986d960bb568593a3a9281ff2557bb6c38de31e68a"
|
||||
}
|
||||
],
|
||||
"admitted_type_versions": [
|
||||
"1.1"
|
||||
],
|
||||
"admitted_13": false,
|
||||
"tests": {
|
||||
"passed": 19,
|
||||
"baseline_expected_failures": 2,
|
||||
"isolation": "bwrap unshare-net; only /usr /lib /lib64 plus scratch; no host home/config; empty HOME; allowlisted env; fake bao only"
|
||||
},
|
||||
"review_inputs": [
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "8d2f1a165e3f4eecf2e6e1d6a19c4bac9e8dcb64a76650e7185d55722f233c8b"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "INTENT.md",
|
||||
"sha256": "12c6540735af591f7a4a9fe85ff10d3cd00eec55b5b6b1ddf148abf2d3ac9a9c"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "credential-change-requests/CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml",
|
||||
"sha256": "84e44202d7d2dfad9955f00b461b587995ba275b05ff29fef94c085810758560"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json",
|
||||
"sha256": "ffcf056bdd5d39797efd589197e75f880ecbf784c23eb0a1516f64fe4f430dfb"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "security-case-types/oidc-browser-relying-party/type.yaml",
|
||||
"sha256": "f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "security-case-type-revisions/1.2/oidc-browser-relying-party/type.yaml",
|
||||
"sha256": "9e60ff569fb0d860a2bbde7bbb0991a54d4b0688f62b56df0fa5d161fd624578"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "security-case-type-revisions/1.2/oidc-browser-relying-party/decision/acknowledgment.json",
|
||||
"sha256": "2dcc220febf125a17b3769f7014f18b63979d7121e0f7056a2b0d8b1ae6031b0"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml",
|
||||
"sha256": "ef02ddae3167bdd1558193a5f6db0379fb8cc84207531706d5ef35caabc3684f"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "tools/security-case-types/security_case_validator.py",
|
||||
"sha256": "1bcc62e9bc374c711363295549f3cd5bb0b32952671ed4b08fddc7f74742959b"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "tools/security-scenario-composer/security_scenario_composer.py",
|
||||
"sha256": "9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "tools/playbook-capability-contract/playbook_contract_validator.py",
|
||||
"sha256": "2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d"
|
||||
},
|
||||
{
|
||||
"repo": "ops-warden",
|
||||
"path": "src/warden/case_inputs.py",
|
||||
"sha256": "1334b0cd9897e763cfea2d306e997c659f3f284ecc3f9612140c2104835da7c7"
|
||||
},
|
||||
{
|
||||
"repo": "ops-warden",
|
||||
"path": "src/warden/case_plan.py",
|
||||
"sha256": "9352ae8683d1181980c4ff38524582e4be448d33c85b558f60582c1e5bb5bbaa"
|
||||
},
|
||||
{
|
||||
"repo": "ops-warden",
|
||||
"path": "src/warden/case_run.py",
|
||||
"sha256": "1bc73e30ac718fd75bbaff2a739495fc051aeaec73802ac71f0583cb2d53ab0a"
|
||||
}
|
||||
],
|
||||
"admitted_12": false,
|
||||
"rpf_13_t1_fully_satisfied": false,
|
||||
"authenticated_case_admission": "open; reference/schema validation is insufficient"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
git apply --check against exact four baseline files: 0
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
--- a/scripts/keycape_client_secret_generate.py
|
||||
+++ b/scripts/keycape_client_secret_generate.py
|
||||
@@ -16,7 +16,7 @@
|
||||
the broker issuer policy for this grant.
|
||||
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
|
||||
bytes) and require: it equals ``--case-ref``; type
|
||||
- ``oidc-browser-relying-party@1.1``; status ``materialized``; client_type
|
||||
+ an exactly source-pinned acknowledged browser revision; status ``materialized``; client_type
|
||||
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
|
||||
matches the type's pattern and is not a reserved entry.
|
||||
3. Mint a 5-minute grant token through token role
|
||||
@@ -46,6 +46,7 @@
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
+import tempfile
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
@@ -57,7 +58,39 @@
|
||||
PREFIX = 'workloads/keycape'
|
||||
RESERVED = {'secrets-engine-openbao'}
|
||||
FIELD = 'CLIENT_SECRET'
|
||||
-CASE_TYPE = 'oidc-browser-relying-party@1.1'
|
||||
+# Owner configuration, never a caller-supplied root or a version search/fallback.
|
||||
+# The sibling source checkout must contain these exact reviewed bytes.
|
||||
+NETKINGDOM_ROOT = Path(__file__).resolve().parents[2] / 'net-kingdom'
|
||||
+ACKNOWLEDGED_TYPES = {
|
||||
+ 'oidc-browser-relying-party@1.1': (
|
||||
+ 'security-case-types/oidc-browser-relying-party/type.yaml',
|
||||
+ 'f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c'),
|
||||
+}
|
||||
+# Same authenticated producer and parameter projection as ops-warden case_inputs
|
||||
+# at b593c879, restricted to this broker's currently admitted 1.1. Source-only
|
||||
+# acknowledgment (including 1.2) never expands broker admission. A future 1.3
|
||||
+# entry requires real acknowledgment and a separately reviewed admission binding.
|
||||
+# Neither source status nor caller-supplied YAML expands this set.
|
||||
+SOURCE_PINS = {
|
||||
+ 'tools/security-case-types/security_case_validator.py':
|
||||
+ '1bcc62e9bc374c711363295549f3cd5bb0b32952671ed4b08fddc7f74742959b',
|
||||
+ 'tools/security-scenario-composer/security_scenario_composer.py':
|
||||
+ '9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f',
|
||||
+ 'tools/playbook-capability-contract/playbook_contract_validator.py':
|
||||
+ '2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d',
|
||||
+}
|
||||
+PARAMETER_PINS = {
|
||||
+ '1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c',
|
||||
+}
|
||||
+_VALIDATE = """
|
||||
+import json, sys
|
||||
+from pathlib import Path
|
||||
+sys.path.insert(0, str(Path(sys.argv[1]) / 'tools/security-case-types'))
|
||||
+import security_case_validator as v
|
||||
+case, typ = json.load(sys.stdin)
|
||||
+issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False)
|
||||
+print('valid' if not v.has_errors(issues) else 'invalid')
|
||||
+"""
|
||||
# oidc-browser-relying-party@1.1 parameter client_id.
|
||||
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
|
||||
@@ -93,6 +126,53 @@
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
raise Exit(code, 'unparseable_bao_output') from None
|
||||
+
|
||||
+
|
||||
+def authenticated_source(relative, expected):
|
||||
+ path = NETKINGDOM_ROOT / relative
|
||||
+ # Refuse symlinks at every selected component, including the configured root.
|
||||
+ if any(part.is_symlink() for part in (path, *path.parents)):
|
||||
+ raise ValueError
|
||||
+ raw = path.read_bytes()
|
||||
+ if hashlib.sha256(raw).hexdigest() != expected:
|
||||
+ raise ValueError
|
||||
+ return raw
|
||||
+
|
||||
+
|
||||
+def check_acknowledged_case(doc):
|
||||
+ # Authenticates the selected TYPE/producer bytes and validates case schema.
|
||||
+ # The caller's self-hashed case reference is not authenticated case admission
|
||||
+ # or owner acceptance. Those require independent Warden/native evidence;
|
||||
+ # this source-only hardening does not complete the real admission binding.
|
||||
+ binding = ACKNOWLEDGED_TYPES.get(doc['spec'].get('type'))
|
||||
+ if binding is None:
|
||||
+ raise Exit(2, 'case_type_unacknowledged')
|
||||
+ try:
|
||||
+ typ = yaml.safe_load(authenticated_source(*binding))
|
||||
+ spec = typ['spec']
|
||||
+ version = str(spec['version'])
|
||||
+ fingerprint = hashlib.sha256(json.dumps(
|
||||
+ spec['parameters'], sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
+ if (spec['status'] not in ('acknowledged', 'implemented')
|
||||
+ or f"{spec['id']}@{version}" != doc['spec']['type']
|
||||
+ or PARAMETER_PINS.get(version) != fingerprint):
|
||||
+ raise ValueError
|
||||
+ with tempfile.TemporaryDirectory(prefix='platform-case-validator-') as directory:
|
||||
+ for relative, expected in SOURCE_PINS.items():
|
||||
+ raw = authenticated_source(relative, expected)
|
||||
+ dest = Path(directory) / relative
|
||||
+ dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
+ dest.write_bytes(raw)
|
||||
+ result = subprocess.run(
|
||||
+ [sys.executable, '-I', '-c', _VALIDATE, directory],
|
||||
+ input=json.dumps([doc, typ]), text=True,
|
||||
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
+ env={}, timeout=10, check=True)
|
||||
+ if result.stdout != 'valid\n':
|
||||
+ raise ValueError
|
||||
+ except (OSError, ValueError, TypeError, KeyError, yaml.YAMLError,
|
||||
+ subprocess.SubprocessError):
|
||||
+ raise Exit(2, 'case_source_validation_failed') from None
|
||||
|
||||
|
||||
def check_case(args, receipt):
|
||||
@@ -112,14 +192,15 @@
|
||||
derived = f"{spec.get('id')}@sha256:{hashlib.sha256(raw).hexdigest()}"
|
||||
if derived != args.case_ref:
|
||||
raise Exit(2, 'case_ref_mismatch')
|
||||
- if spec.get('type') != CASE_TYPE:
|
||||
- raise Exit(2, 'case_type_mismatch')
|
||||
+ if not isinstance(spec.get('type'), str):
|
||||
+ raise Exit(2, 'case_type_unacknowledged')
|
||||
if spec.get('status') != 'materialized':
|
||||
raise Exit(2, 'case_not_materialized')
|
||||
if params.get('client_type') != 'confidential':
|
||||
raise Exit(2, 'case_client_not_confidential')
|
||||
if params.get('client_id') != args.client_id:
|
||||
raise Exit(2, 'case_client_id_mismatch')
|
||||
+ check_acknowledged_case(doc)
|
||||
receipt['case_verified'] = True
|
||||
|
||||
|
||||
--- a/credential-grants/catalog.yaml
|
||||
+++ b/credential-grants/catalog.yaml
|
||||
@@ -137,9 +137,9 @@
|
||||
requires_human_above: 5m
|
||||
actors:
|
||||
allowed_types:
|
||||
- - approved-agent
|
||||
- human-operator
|
||||
- required_subject_binding: keycape-or-kubernetes-service-account
|
||||
+ required_subject_binding: auth/netkingdom/role/credential-broker-keycape-client-secret-generate
|
||||
+ # CCR-2026-0043: human net-kingdom-admins membership and /assurance/level=aal2.
|
||||
authorization:
|
||||
flex_auth_required: false
|
||||
flex_auth_mode: optional-preflight
|
||||
--- a/tests/test_keycape_client_secret_generate.py
|
||||
+++ b/tests/test_keycape_client_secret_generate.py
|
||||
@@ -6,6 +6,11 @@
|
||||
caller's session.
|
||||
"""
|
||||
import hashlib
|
||||
+import importlib.util
|
||||
+import shutil
|
||||
+from unittest import mock
|
||||
+
|
||||
+import yaml
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -75,7 +80,9 @@
|
||||
def run_cli(self, *extra, client_id='forgejo', ref=None, env_extra=None):
|
||||
(self.tmp / 'state.json').write_text(json.dumps(self.state))
|
||||
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
|
||||
- env = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN', 'WARDEN_AGENT_ID')}
|
||||
+ empty_home = self.tmp / 'empty-home'
|
||||
+ empty_home.mkdir(exist_ok=True)
|
||||
+ env = {'PATH': os.defpath, 'HOME': str(empty_home)}
|
||||
env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', FAKE_BAO_STATE=str(self.tmp / 'state.json'),
|
||||
**(env_extra or {}))
|
||||
proc = subprocess.run([sys.executable, str(CLI), 'generate', '--grant', 'keycape/client-secret-generate',
|
||||
@@ -149,14 +156,123 @@
|
||||
def test_only_exact_acknowledged_type_version_is_admitted(self):
|
||||
for type_ref in ('oidc-browser-relying-party@1.0',
|
||||
'oidc-browser-relying-party@1.2',
|
||||
+ 'oidc-browser-relying-party@1.3',
|
||||
'oidc-browser-relying-party@99.0',
|
||||
'confidential-service-client@1.1'):
|
||||
with self.subTest(type_ref=type_ref):
|
||||
self.write_case(CASE.replace('oidc-browser-relying-party@1.1', type_ref))
|
||||
self.state['calls'] = []
|
||||
code, receipt = self.run_cli()
|
||||
- self.assertEqual((code, receipt['outcome']), (2, 'case_type_mismatch'))
|
||||
+ self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
+
|
||||
+ def test_source_acknowledgment_alone_does_not_admit_12(self):
|
||||
+ self.write_case(CASE.replace('oidc-browser-relying-party@1.1',
|
||||
+ 'oidc-browser-relying-party@1.2')
|
||||
+ + ' consumer_namespace: forgejo\n')
|
||||
+ code, receipt = self.run_cli()
|
||||
+ self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
|
||||
+ self.assertEqual(self.state['calls'], [])
|
||||
+
|
||||
+ def test_caller_environment_cannot_select_type_or_source_root(self):
|
||||
+ code, receipt = self.run_cli(env_extra={
|
||||
+ 'NETKINGDOM_ROOT': '/nonexistent',
|
||||
+ 'WARDEN_NETKINGDOM_ROOT': '/nonexistent',
|
||||
+ 'PYTHONPATH': '/nonexistent',
|
||||
+ })
|
||||
+ self.assertEqual((code, receipt['outcome']), (0, 'generated'))
|
||||
+
|
||||
+ def test_acknowledged_11_still_requires_its_exact_parameters(self):
|
||||
+ self.write_case(CASE + ' unexpected_target: forgejo\n')
|
||||
+ code, receipt = self.run_cli()
|
||||
+ self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
|
||||
+ self.assertEqual(self.state['calls'], [])
|
||||
+
|
||||
+ def test_future_revision_requires_explicit_owner_binding_and_acknowledgment(self):
|
||||
+ # Synthetic owner configuration exercises the extension mechanism only.
|
||||
+ # This is not the actual proposed 1.3 type, acknowledgment or parameter pin.
|
||||
+ spec = importlib.util.spec_from_file_location(
|
||||
+ 'future_fixture_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
|
||||
+ broker = importlib.util.module_from_spec(spec)
|
||||
+ spec.loader.exec_module(broker)
|
||||
+ source = self.tmp / 'synthetic-owner-root'
|
||||
+ source.mkdir()
|
||||
+ relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
|
||||
+ for rel in (relative, *broker.SOURCE_PINS):
|
||||
+ dest = source / rel
|
||||
+ dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
+ shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
|
||||
+ typ = source / relative
|
||||
+ original = typ.read_bytes()
|
||||
+ doc = yaml.safe_load(CASE)
|
||||
+ doc['spec']['type'] = 'oidc-browser-relying-party@1.3'
|
||||
+ typ.write_bytes(original.replace(b'version: "1.1"', b'version: "1.3"'))
|
||||
+ # YAML numeric spelling is owner-authored, never an inferred revision.
|
||||
+ type_doc = yaml.safe_load(original)
|
||||
+ type_doc['spec']['version'] = '1.3'
|
||||
+ typ.write_text(yaml.safe_dump(type_doc))
|
||||
+ binding = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
|
||||
+ with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
|
||||
+ with self.assertRaises(broker.Exit):
|
||||
+ broker.check_acknowledged_case(doc) # no explicit binding
|
||||
+ with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, binding):
|
||||
+ with self.assertRaises(broker.Exit):
|
||||
+ broker.check_acknowledged_case(doc) # no parameter admission
|
||||
+ with mock.patch.dict(broker.PARAMETER_PINS, {'1.3': broker.PARAMETER_PINS['1.1']}):
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ type_doc['spec']['status'] = 'proposed'
|
||||
+ typ.write_text(yaml.safe_dump(type_doc))
|
||||
+ proposed = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
|
||||
+ with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, proposed):
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
+
|
||||
+ def test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed(self):
|
||||
+ spec = importlib.util.spec_from_file_location(
|
||||
+ 'candidate_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
|
||||
+ broker = importlib.util.module_from_spec(spec)
|
||||
+ spec.loader.exec_module(broker)
|
||||
+ source = self.tmp / 'net-kingdom'
|
||||
+ source.mkdir()
|
||||
+ relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
|
||||
+ for rel in (relative, *broker.SOURCE_PINS):
|
||||
+ dest = source / rel
|
||||
+ dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
+ shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
|
||||
+ doc = yaml.safe_load(CASE)
|
||||
+ with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ producer = source / next(iter(broker.SOURCE_PINS))
|
||||
+ raw = producer.read_bytes()
|
||||
+ producer.write_bytes(raw + b'\n# drift\n')
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
+ producer.write_bytes(raw)
|
||||
+ typ = source / relative
|
||||
+ raw = typ.read_bytes()
|
||||
+ typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed'))
|
||||
+ # Even a hypothetical owner pin update cannot admit proposed status.
|
||||
+ with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
|
||||
+ doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}):
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
+ typ.write_bytes(raw + b'\n# source drift\n')
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
+ typ.unlink()
|
||||
+ typ.symlink_to(broker.__file__)
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
|
||||
+ for ref in ('oidc-browser-relying-party@1.3', 'oidc-browser-relying-party@99.0'):
|
||||
+ doc['spec']['type'] = ref
|
||||
+ with self.assertRaises(broker.Exit) as caught:
|
||||
+ broker.check_acknowledged_case(doc)
|
||||
+ self.assertEqual(caught.exception.reason, 'case_type_unacknowledged')
|
||||
|
||||
def test_exact_accepted_case_still_requires_materialization(self):
|
||||
self.write_case(CASE.replace('status: materialized', 'status: accepted'))
|
||||
--- a/tests/test_credential_grants.py
|
||||
+++ b/tests/test_credential_grants.py
|
||||
@@ -24,6 +24,20 @@
|
||||
for index, grant in enumerate(catalog["grants"]):
|
||||
grants.validate_grant(grant, index, catalog, errors)
|
||||
self.assertEqual(errors, [])
|
||||
+
|
||||
+ def test_keycape_generator_actor_matches_applied_issuer_binding(self) -> None:
|
||||
+ catalog = grants.yaml.safe_load(
|
||||
+ (REPO_DIR / 'credential-grants/catalog.yaml').read_text())
|
||||
+ grant = next(g for g in catalog['grants'] if g['id'] == 'keycape/client-secret-generate')
|
||||
+ ccr = grants.yaml.safe_load((REPO_DIR / 'credential-change-requests' /
|
||||
+ 'CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml').read_text())
|
||||
+ role = grants.yaml.safe_load((REPO_DIR / ccr['openbao']['auth']['role_file']).read_text())
|
||||
+ auth = ccr['openbao']['auth']
|
||||
+ self.assertEqual(grant['actors']['allowed_types'], ['human-operator'])
|
||||
+ self.assertEqual(grant['actors']['required_subject_binding'],
|
||||
+ f"auth/{auth['mount']}/role/{auth['role']}")
|
||||
+ self.assertEqual(role['bound_claims'],
|
||||
+ {'groups': ['net-kingdom-admins'], '/assurance/level': 'aal2'})
|
||||
|
||||
def test_postgres_grants_bind_rapp(self) -> None:
|
||||
catalog = grants.yaml.safe_load(
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
root=Path('/tmp/platform-broker-r1/railiance-platform')
|
||||
files=['scripts/keycape_client_secret_generate.py','credential-grants/catalog.yaml']
|
||||
saved={p:(root/p).read_bytes() for p in files}
|
||||
try:
|
||||
for p in files:
|
||||
raw=subprocess.check_output(['git','-C','/home/worsch/railiance-platform','show','104ed90f42a9a86bf9fda343b49b65a85c9ee4b9:'+p])
|
||||
(root/p).write_bytes(raw)
|
||||
command=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind','/tmp/platform-broker-r1','/tmp/platform-broker-r1','--chdir',str(root/'tests'),'--','env','-i','PATH=/usr/local/bin:/usr/bin:/bin','HOME=/tmp/platform-broker-r1/empty-home','PYTHONDONTWRITEBYTECODE=1','python3','-m','unittest','test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters','test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding','-v']
|
||||
result=subprocess.run(command,capture_output=True,text=True)
|
||||
log=Path('/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/platform-broker-followthrough-r1/baseline-reproduction.log')
|
||||
log.write_text('Baseline Platform 104ed90f with candidate regressions; expected two failures.\n'+result.stdout+result.stderr)
|
||||
assert result.returncode==1 and 'FAIL: test_acknowledged_11' in result.stderr and 'FAIL: test_keycape_generator_actor' in result.stderr
|
||||
finally:
|
||||
for p,raw in saved.items(): (root/p).write_bytes(raw)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
bwrap --unshare-net --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --symlink usr/bin /bin --proc /proc --dev /dev --tmpfs /tmp --bind /tmp/platform-broker-r1 /tmp/platform-broker-r1 --chdir /tmp/platform-broker-r1/railiance-platform -- env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp/platform-broker-r1/empty-home PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p test_keycape_client_secret_generate.py -v
|
||||
bwrap --unshare-net --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --symlink usr/bin /bin --proc /proc --dev /dev --tmpfs /tmp --bind /tmp/platform-broker-r1 /tmp/platform-broker-r1 --chdir /tmp/platform-broker-r1/railiance-platform -- env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp/platform-broker-r1/empty-home PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p test_credential_grants.py -v
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
test_acknowledged_11_still_requires_its_exact_parameters (test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters) ... ok
|
||||
test_agents_root_and_unbound_callers_refused (test_keycape_client_secret_generate.Generate.test_agents_root_and_unbound_callers_refused) ... ok
|
||||
test_caller_environment_cannot_select_type_or_source_root (test_keycape_client_secret_generate.Generate.test_caller_environment_cannot_select_type_or_source_root) ... ok
|
||||
test_case_checks_refuse_before_any_bao_call (test_keycape_client_secret_generate.Generate.test_case_checks_refuse_before_any_bao_call) ... ok
|
||||
test_dry_run_writes_nothing (test_keycape_client_secret_generate.Generate.test_dry_run_writes_nothing) ... ok
|
||||
test_exact_accepted_case_still_requires_materialization (test_keycape_client_secret_generate.Generate.test_exact_accepted_case_still_requires_materialization) ... ok
|
||||
test_existing_entry_is_never_touched (test_keycape_client_secret_generate.Generate.test_existing_entry_is_never_touched) ... ok
|
||||
test_future_revision_requires_explicit_owner_binding_and_acknowledgment (test_keycape_client_secret_generate.Generate.test_future_revision_requires_explicit_owner_binding_and_acknowledgment) ... ok
|
||||
test_generates_once_with_grant_token_and_revokes_it (test_keycape_client_secret_generate.Generate.test_generates_once_with_grant_token_and_revokes_it) ... ok
|
||||
test_lost_cas_race_reports_already_present (test_keycape_client_secret_generate.Generate.test_lost_cas_race_reports_already_present) ... ok
|
||||
test_only_exact_acknowledged_type_version_is_admitted (test_keycape_client_secret_generate.Generate.test_only_exact_acknowledged_type_version_is_admitted) ... ok
|
||||
test_source_acknowledgment_alone_does_not_admit_12 (test_keycape_client_secret_generate.Generate.test_source_acknowledgment_alone_does_not_admit_12) ... ok
|
||||
test_unmaterialized_or_public_case_refused (test_keycape_client_secret_generate.Generate.test_unmaterialized_or_public_case_refused) ... ok
|
||||
test_write_failure_is_reported_and_token_revoked (test_keycape_client_secret_generate.Generate.test_write_failure_is_reported_and_token_revoked) ... ok
|
||||
test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed (test_keycape_client_secret_generate.Generate.test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 15 tests in 6.051s
|
||||
|
||||
OK
|
||||
test_catalog_validates (test_credential_grants.CredentialGrantBindTests.test_catalog_validates) ... ok
|
||||
test_keycape_generator_actor_matches_applied_issuer_binding (test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding) ... ok
|
||||
test_postgres_grants_bind_rapp (test_credential_grants.CredentialGrantBindTests.test_postgres_grants_bind_rapp) ... ok
|
||||
test_rapp_prefix_requires_matching_rapp_id (test_credential_grants.CredentialGrantBindTests.test_rapp_prefix_requires_matching_rapp_id) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 4 tests in 0.077s
|
||||
|
||||
OK
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
id: RPF-WP-0060
|
||||
type: workplan
|
||||
title: "KeyCape 1.3 Platform capability and broker follow-through"
|
||||
domain: financials
|
||||
repo: railiance-platform
|
||||
status: proposed
|
||||
owner: codex
|
||||
topic_slug: platform-secret-custody
|
||||
created: "2026-10-17"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
origin: NK-WP-0048-T16
|
||||
---
|
||||
|
||||
Source draft only. RPF-WP-0060 was the next free number inspected at Platform
|
||||
104ed90f42a9a86bf9fda343b49b65a85c9ee4b9. The coordinator must recheck and
|
||||
allocate it in Platform before publication. No Hub UUIDs are manually assigned.
|
||||
The owner-review RPF-1.3-T1 through T4 strings were review labels, not task IDs.
|
||||
|
||||
The review is
|
||||
`the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-platform.json`.
|
||||
The candidate for T01/T02 is retained alongside this draft in
|
||||
`platform-broker-followthrough-r1/`. This draft does not record owner acceptance,
|
||||
a 1.3 acknowledgment, a case acceptance, a native grant, or completion of source
|
||||
integration. No live policy change belongs to T01/T02.
|
||||
|
||||
## Exact acknowledged broker admission (review label RPF-1.3-T1)
|
||||
|
||||
```task
|
||||
id: RPF-WP-0060-T01
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Review and integrate the isolated broker candidate with exact known acknowledged
|
||||
1.1 source selection, pinned validator closure and parameter fingerprints,
|
||||
case validation, and refusal before native calls. Run the offline regressions.
|
||||
1.2 remains refused despite source-only acknowledgment.
|
||||
1.3 remains refused until its actual acknowledgment and exact source selection
|
||||
are established and separately reviewed; no speculative 1.3 pin is present.
|
||||
Maintain the case-bytes reference check and materialized/confidential target
|
||||
binding. Independently establish authenticated case admission/owner acceptance;
|
||||
self-hashed caller case bytes and schema validation do not establish it. That
|
||||
real admission binding remains open and prevents claiming this task complete. Review the fixed sibling source-installation requirement before merge.
|
||||
|
||||
## Correct the issuer catalog statement (review label RPF-1.3-T2)
|
||||
|
||||
```task
|
||||
id: RPF-WP-0060-T02
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Review and integrate the metadata-only catalog correction to human-operator and
|
||||
`auth/netkingdom/role/credential-broker-keycape-client-secret-generate`, matching
|
||||
applied CCR-2026-0043. Run the regression against the role source, issuer group
|
||||
and aal2 binding. No role, policy, credential or live grant changes.
|
||||
|
||||
## Real mint and rollback capability bindings (review label RPF-1.3-T3)
|
||||
|
||||
```task
|
||||
id: RPF-WP-0060-T03
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Design and implement the generator-to-custody-mint.v1 projection, authenticated
|
||||
publication, grant accessor/revocation evidence and caller-session revocation.
|
||||
Destroy requires attended platform-admin authority with exact version-1,
|
||||
created-time equality and no-other-version compare-before-delete. Preserve
|
||||
UNCERTAIN on unavailable authority or revocation failure. Obtain required owner
|
||||
review and authority before any native operation. Use offline adapter tests.
|
||||
The source candidate does not implement or satisfy this task.
|
||||
|
||||
## Delivery lane and capability (review label RPF-1.3-T4)
|
||||
|
||||
```task
|
||||
id: RPF-WP-0060-T04
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Define platform-secret-delivery with human net-kingdom-admins/aal2 sessions and
|
||||
contained named ExternalSecret authority in sso, no Secret reads. Implement
|
||||
case-derived spec equality for NO_CHANGE, Ready=True status gate, exact metadata
|
||||
receipts, and uid/resourceVersion preconditioned removal. ESO reads custody;
|
||||
this human lane creates the ExternalSecret. Route the proposed catalog entry
|
||||
to ops-warden through an explicitly authorized owner handoff. Native authority
|
||||
and catalog acceptance remain separate gates. The candidate implements none.
|
||||
|
||||
Owner-descriptor-acceptance is still gated on completed owner obligations and
|
||||
review. Publishing this workplan does not satisfy those obligations. Any
|
||||
unfinished actionable work must remain in these tasks or a live residual record.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
Baseline Platform 104ed90f with candidate regressions; expected two failures.
|
||||
test_acknowledged_11_still_requires_its_exact_parameters (test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters) ... FAIL
|
||||
test_keycape_generator_actor_matches_applied_issuer_binding (test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding) ... FAIL
|
||||
|
||||
======================================================================
|
||||
FAIL: test_acknowledged_11_still_requires_its_exact_parameters (test_keycape_client_secret_generate.Generate.test_acknowledged_11_still_requires_its_exact_parameters)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/platform-broker-independent-r1/railiance-platform/tests/test_keycape_client_secret_generate.py", line 188, in test_acknowledged_11_still_requires_its_exact_parameters
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
|
||||
AssertionError: Tuples differ: (0, 'generated') != (2, 'case_source_validation_failed')
|
||||
|
||||
First differing element 0:
|
||||
0
|
||||
2
|
||||
|
||||
- (0, 'generated')
|
||||
+ (2, 'case_source_validation_failed')
|
||||
|
||||
======================================================================
|
||||
FAIL: test_keycape_generator_actor_matches_applied_issuer_binding (test_credential_grants.CredentialGrantBindTests.test_keycape_generator_actor_matches_applied_issuer_binding)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/platform-broker-independent-r1/railiance-platform/tests/test_credential_grants.py", line 36, in test_keycape_generator_actor_matches_applied_issuer_binding
|
||||
self.assertEqual(grant['actors']['allowed_types'], ['human-operator'])
|
||||
AssertionError: Lists differ: ['approved-agent', 'human-operator'] != ['human-operator']
|
||||
|
||||
First differing element 0:
|
||||
'approved-agent'
|
||||
'human-operator'
|
||||
|
||||
First list contains 1 additional elements.
|
||||
First extra element 1:
|
||||
'human-operator'
|
||||
|
||||
- ['approved-agent', 'human-operator']
|
||||
+ ['human-operator']
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 2 tests in 0.361s
|
||||
|
||||
FAILED (failures=2)
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"""Offline reviewer controls; fake Bao fixture only, run inside supplied bwrap boundary."""
|
||||
import hashlib, importlib.util, json, pathlib, sys
|
||||
ROOT=pathlib.Path('/tmp/platform-broker-independent-r1')
|
||||
sys.path.insert(0,str(ROOT/'railiance-platform/tests'))
|
||||
import test_keycape_client_secret_generate as fixture
|
||||
spec=importlib.util.spec_from_file_location('reviewed_broker',ROOT/'railiance-platform/scripts/keycape_client_secret_generate.py')
|
||||
broker=importlib.util.module_from_spec(spec);spec.loader.exec_module(broker)
|
||||
results=[]
|
||||
def cli_probe(name, change=None, env=None, accepted=False):
|
||||
t=fixture.Generate();t.setUp()
|
||||
if change:t.write_case(change(t.case.read_text()))
|
||||
code,receipt=t.run_cli(env_extra=env)
|
||||
if accepted:
|
||||
assert (code,receipt['outcome'])==(0,'generated'),receipt
|
||||
else:
|
||||
assert code!=0 and t.state['calls']==[],(code,receipt,t.state)
|
||||
results.append({'name':name,'code':code,'outcome':receipt['outcome'],'bao_calls':len(t.state['calls'])})
|
||||
# Every executable producer pin is independently necessary at the real CLI boundary.
|
||||
for relative in broker.SOURCE_PINS:
|
||||
path=ROOT/'net-kingdom'/relative;raw=path.read_bytes()
|
||||
try:
|
||||
path.write_bytes(raw+b'\n# independent source mutation\n')
|
||||
cli_probe('producer drift: '+relative)
|
||||
path.unlink()
|
||||
cli_probe('producer absent: '+relative)
|
||||
finally:path.write_bytes(raw)
|
||||
# Layout requirement and symlink root refusal, without any runtime root overrides.
|
||||
nk=ROOT/'net-kingdom';saved=ROOT/'net-kingdom-saved';nk.rename(saved)
|
||||
try:
|
||||
cli_probe('fixed sibling missing')
|
||||
nk.symlink_to(saved,target_is_directory=True)
|
||||
cli_probe('fixed sibling symlink refused')
|
||||
finally:
|
||||
if nk.is_symlink():nk.unlink()
|
||||
saved.rename(nk)
|
||||
# No ambient producer import, site hook or YAML dependency from case directory.
|
||||
poison=ROOT/'poison';poison.mkdir(exist_ok=True)
|
||||
(poison/'yaml.py').write_text("raise RuntimeError('caller yaml imported')\n")
|
||||
(poison/'sitecustomize.py').write_text("raise RuntimeError('caller site hook imported')\n")
|
||||
cli_probe('honest case with hostile source-root environment',env={'NETKINGDOM_ROOT':str(poison),'WARDEN_NETKINGDOM_ROOT':str(poison)},accepted=True)
|
||||
# The broker itself is launched normally; the pinned producer process is the isolated one.
|
||||
# Prove -I and env={} on that actual subprocess, then let it run normally.
|
||||
import unittest.mock as mock
|
||||
original=broker.subprocess.run
|
||||
calls=[]
|
||||
def observe(argv,**kwargs):
|
||||
calls.append((argv,kwargs))
|
||||
return original(argv,**kwargs)
|
||||
with mock.patch.object(broker.subprocess,'run',side_effect=observe):
|
||||
broker.check_acknowledged_case(fixture.yaml.safe_load(fixture.CASE))
|
||||
assert len(calls)==1 and calls[0][0][1:3]==['-I','-c'] and calls[0][1]['env']=={}
|
||||
assert 'PYTHONPATH' not in calls[0][1]['env']
|
||||
results.append({'name':'validator exact interpreter isolation','result':'-I and empty env observed on actual invocation'})
|
||||
# Authenticated case acceptance remains absent: structurally plausible invented metadata passes.
|
||||
cli_probe('fabricated acceptance record (known admission limit)',change=lambda s:s.replace('record: "key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e"','record: "fabricated reviewer test; no owner acceptance"'),accepted=True)
|
||||
cli_probe('missing acceptance metadata refused',change=lambda s:s.replace(' acceptance:\n',' removed_acceptance:\n'))
|
||||
cli_probe('parameter pattern refused',change=lambda s:s.replace(' redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback',' redirect_uris: http://invalid.example/callback'))
|
||||
print(json.dumps({'controls':results,'count':len(results),'all_expected':True},indent=2))
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"schema": "custodian.platform-broker-independent-review.v1",
|
||||
"reviewed_on": "2026-10-17",
|
||||
"candidate_manifest_sha256": "61c15ae8d12b3665eb0e210cc0a1b0a238f52026258a59987657fd77f8d6c0ba",
|
||||
"candidate_patch_sha256": "b0b85153d04a28d4bb05ab39a9b54c782c9efa54bd80ca961e2bcc6348a9b546",
|
||||
"source_commits": {
|
||||
"railiance-platform": "104ed90f42a9a86bf9fda343b49b65a85c9ee4b9",
|
||||
"net-kingdom": "befb0bc68cd25b625804fce559476eefd702521d",
|
||||
"ops-warden": "b593c879831378db7dc62adfabdfedf2d44ddd38"
|
||||
},
|
||||
"manifest_all_preimages_postimages_review_inputs_verified": true,
|
||||
"candidate_restored_after_baseline": true,
|
||||
"tests": {
|
||||
"candidate_broker_passed": 15,
|
||||
"candidate_catalog_passed": 4,
|
||||
"baseline_expected_failures_reproduced": 2,
|
||||
"independent_controls_passed": 13,
|
||||
"controls": [
|
||||
"each of three producer files drifted, CLI refuses before fake Bao",
|
||||
"each of three producer files removed, CLI refuses before fake Bao",
|
||||
"fixed sibling missing refuses before fake Bao",
|
||||
"fixed sibling root symlink refuses before fake Bao",
|
||||
"source-root environment override ignored",
|
||||
"actual validator subprocess uses -I and empty environment",
|
||||
"fabricated acceptance record still reaches fake generation (documented existing admission gap)",
|
||||
"missing case acceptance refuses before fake Bao",
|
||||
"invalid redirect URI refuses before fake Bao"
|
||||
],
|
||||
"isolation": "bwrap unshare-net; allowlisted environment; no host home/config; fake Bao only"
|
||||
},
|
||||
"verdict": "admissible for bounded owner source integration; no blocking source-candidate defect found",
|
||||
"source_installation": "Compatible with the documented ~/railiance-platform + sibling ~/net-kingdom checkout invocation; same exact layout exercised in independent scratch. No packaged/copied broker installation proved. Missing, drifted or symlinked source fails closed. Dependency needs explicit retention in owner integration documentation.",
|
||||
"producer_closure": "Three pinned imported modules close validate_type + validate_case. Warden additionally pins security_case_evidence.py, only imported by CLI check-evidence; this broker never calls that path. Standard library and system PyYAML remain runtime dependencies under the existing Warden interpreter pattern.",
|
||||
"current_admission": [
|
||||
"oidc-browser-relying-party@1.1"
|
||||
],
|
||||
"source_only_acknowledged_12_refused": true,
|
||||
"proposed_13_refused": true,
|
||||
"catalog": "Human-only exact issuer role binding matches CCR-2026-0043 applied source and role net-kingdom-admins + aal2. No live verification was made.",
|
||||
"authenticated_case_admission": "OPEN: caller can replace acceptance.record with fabricated structurally valid text, rehash case bytes, and pass schema validation. Independent fake-only probe demonstrated this. Source hardening does not complete T1 or establish owner acceptance.",
|
||||
"real_capability_gates": [
|
||||
"Actual 1.3 acknowledgment and exact owner reviewed admission bindings",
|
||||
"Authenticated case admission/owner acceptance",
|
||||
"Generator projection to exact custody-mint schema",
|
||||
"Grant accessor and independently authenticated revocation evidence",
|
||||
"Caller-session revocation and authenticated evidence publication",
|
||||
"Attended exact minted version compare-before-delete destroy capability",
|
||||
"Platform secret delivery lane, Warden catalog acceptance, deliver/remove capabilities with ready/spec/precondition checks",
|
||||
"Formal owner-descriptor-acceptance after obligations and review"
|
||||
],
|
||||
"formal_owner_assent_created": false,
|
||||
"canonical_writes": false,
|
||||
"credential_native_network_model_calls": false,
|
||||
"correction_patch": "none; no new source defect found",
|
||||
"workplan": "RPF-WP-0060 remains tentative; parent coordinator allocates/publishes legitimate owner records",
|
||||
"source_integration_verdict": "accept",
|
||||
"source_integration_scope": "Exactly the four candidate source postimages from the reviewed patch against Platform 104ed90f42a9a86bf9fda343b49b65a85c9ee4b9; source integration only, without authority or completion claims.",
|
||||
"reviewed_outputs": [
|
||||
{
|
||||
"path": "scripts/keycape_client_secret_generate.py",
|
||||
"sha256": "da80947a2c28df36b37e711d1e6d2f66b4c0434082e7bf187da3d3cb692cec28"
|
||||
},
|
||||
{
|
||||
"path": "credential-grants/catalog.yaml",
|
||||
"sha256": "7cfe12970a57ab75cb91c7c5024a4dcfc28ffae4b31a1afaf4d0cedabbb22886"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_keycape_client_secret_generate.py",
|
||||
"sha256": "5906e32c7fe0db09c1f7db48ad61226497e062f38ebfa91e8a03be89272765c0"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_credential_grants.py",
|
||||
"sha256": "5c0cfcb039c0220e59dac7986d960bb568593a3a9281ff2557bb6c38de31e68a"
|
||||
}
|
||||
],
|
||||
"t1_complete": false,
|
||||
"integration_conditions": [
|
||||
"Preserve explicit fixed sibling net-kingdom checkout dependency and exact source pins; missing/drifted/symlinked source refusal is intentional.",
|
||||
"Keep 1.2 and 1.3 refused.",
|
||||
"Retain authenticated case admission as open; self-hashed reference/schema validation is insufficient.",
|
||||
"Allocate legitimate owner work records separately; tentative WP60 is not approval evidence."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"""Apply only independently reviewed owner source, using the normal allocator."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = Path('/home/worsch/railiance-platform')
|
||||
PACKET = HERE.parent / 'platform-broker-followthrough-r1'
|
||||
REVIEW = HERE.parent / 'platform-broker-review-r1/review.json'
|
||||
NEW_PLAN = 'workplans/RPF-WP-0060-browser-case-platform-bindings.md'
|
||||
BASE = '104ed90f42a9a86bf9fda343b49b65a85c9ee4b9'
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', '-C', str(ROOT), *args],
|
||||
stderr=subprocess.PIPE, timeout=40).decode().strip()
|
||||
|
||||
|
||||
assert git('rev-parse', 'HEAD') == BASE
|
||||
assert git('ls-remote', 'origin', 'refs/heads/main').split()[0] == BASE
|
||||
assert digest(REVIEW.read_bytes()) == '05d5bf9c2b90a8562ce4675866b8b23c0e12d5d069b43b194948d384b0a553f5'
|
||||
review = json.loads(REVIEW.read_text())
|
||||
assert review['source_integration_verdict'] == 'accept'
|
||||
assert review['t1_complete'] is False
|
||||
manifest = json.loads((PACKET / 'manifest.json').read_text())
|
||||
assert digest((PACKET / 'platform-source.patch').read_bytes()) == review['candidate_patch_sha256']
|
||||
assert {r['path']: r['output_sha256'] for r in manifest['files']} == {
|
||||
r['path']: r['sha256'] for r in review['reviewed_outputs']}
|
||||
for row in manifest['files']:
|
||||
assert digest((ROOT / row['path']).read_bytes()) == row['input_sha256']
|
||||
assert digest((PACKET / 'candidate' / row['path']).read_bytes()) == row['output_sha256']
|
||||
assert not (ROOT / NEW_PLAN).exists()
|
||||
old_plans = {str(p.relative_to(ROOT)): digest(p.read_bytes()) for p in (ROOT / 'workplans').rglob('*.md')}
|
||||
assert not any(re.search(r'^id:\s*RPF-WP-0060\s*$', (ROOT / p).read_text(), re.M) for p in old_plans)
|
||||
dirty = git('diff', '--name-only').splitlines()
|
||||
staged = git('diff', '--cached', '--name-only').splitlines()
|
||||
assert set(dirty + staged) == {'.custodian-brief.md', 'WORK-RECORDS.md'}
|
||||
originals = {p: {'worktree': digest((ROOT / p).read_bytes()), 'index': git('ls-files', '--stage', '--', p)}
|
||||
for p in set(dirty + staged)}
|
||||
sys.path.insert(0, '/home/worsch/repo-manager/src')
|
||||
from repo_manager.identifiers import ensure_missing_work_record_identifiers
|
||||
preview = ensure_missing_work_record_identifiers(ROOT, execute=False)
|
||||
assert preview['ok'] and not preview['assignments'] and not preview['files_changed']
|
||||
for row in manifest['files']:
|
||||
(ROOT / row['path']).write_bytes((PACKET / 'candidate' / row['path']).read_bytes())
|
||||
(ROOT / NEW_PLAN).write_bytes((HERE / 'workplan.md').read_bytes())
|
||||
preview = ensure_missing_work_record_identifiers(ROOT, execute=False)
|
||||
(HERE / 'identifier-preview.json').write_text(json.dumps(preview, indent=2) + '\n')
|
||||
assert preview['ok'] and preview['files_changed'] == [NEW_PLAN]
|
||||
assert len(preview['assignments']) == 5
|
||||
assigned = ensure_missing_work_record_identifiers(ROOT, execute=True)
|
||||
(HERE / 'identifier-assignment.json').write_text(json.dumps(assigned, indent=2) + '\n')
|
||||
assert assigned['ok'] and assigned['files_changed'] == [NEW_PLAN]
|
||||
for p, value in old_plans.items():
|
||||
assert digest((ROOT / p).read_bytes()) == value
|
||||
for p, value in originals.items():
|
||||
assert digest((ROOT / p).read_bytes()) == value['worktree']
|
||||
assert git('ls-files', '--stage', '--', p) == value['index']
|
||||
files = [dict(path=row['path'], sha256=row['output_sha256']) for row in manifest['files']]
|
||||
files.append(dict(path=NEW_PLAN, sha256=digest((ROOT / NEW_PLAN).read_bytes())))
|
||||
cfg = {'owner': 'railiance-platform', 'base_revision': BASE,
|
||||
'message': 'Bind mint broker validation to exact reviewed sources and track successor obligations',
|
||||
'files': files}
|
||||
(HERE / 'publication-inputs.json').write_text(json.dumps(cfg, indent=2) + '\n')
|
||||
(HERE / 'source-application.json').write_text(json.dumps({
|
||||
'base': BASE, 'review_sha256': 'sha256:' + digest(REVIEW.read_bytes()),
|
||||
'files': files, 'existing_workplans_preserved': len(old_plans),
|
||||
'unrelated_state': originals, 'native_actions': False,
|
||||
'tasks': {'RPF-WP-0060-T01': 'progress', 'RPF-WP-0060-T02': 'done',
|
||||
'RPF-WP-0060-T03': 'todo', 'RPF-WP-0060-T04': 'todo'}}, indent=2) + '\n')
|
||||
print(json.dumps({'status': 'applied_source_only', 'files': len(files),
|
||||
'normal_allocator_assignments': len(assigned['assignments']),
|
||||
'existing_workplans_preserved': len(old_plans)}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue