From f5b7ff5118326f66be8ecd8330d4cda9e4d94520 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 4 Oct 2026 02:24:40 +0200 Subject: [PATCH] Implement offline catalog lane scaffold generation Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091 --- docs/catalog-lane-scaffold.md | 63 ++++ ...6-10-04-catalog-lane-scaffold-offline.json | 33 ++ .../platform-request.yaml | 49 +++ .../catalog-lane-scaffold/warden-request.yaml | 15 + schemas/catalog-lane-scaffold.schema.json | 344 ++++++++++++++++++ scripts/catalog_lane_scaffold.py | 203 +++++++++++ scripts/credential-change.py | 7 + tests/test_catalog_lane_scaffold.py | 150 ++++++++ ...-0050-attended-provisioning-preparation.md | 6 +- 9 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 docs/catalog-lane-scaffold.md create mode 100644 docs/evidence/2026-10-04-catalog-lane-scaffold-offline.json create mode 100644 examples/catalog-lane-scaffold/platform-request.yaml create mode 100644 examples/catalog-lane-scaffold/warden-request.yaml create mode 100644 schemas/catalog-lane-scaffold.schema.json create mode 100644 scripts/catalog_lane_scaffold.py create mode 100644 tests/test_catalog_lane_scaffold.py diff --git a/docs/catalog-lane-scaffold.md b/docs/catalog-lane-scaffold.md new file mode 100644 index 0000000..3c66d6e --- /dev/null +++ b/docs/catalog-lane-scaffold.md @@ -0,0 +1,63 @@ +# Offline exact KV-v2 workload-read lane scaffold + +Owner: railiance-platform, RPF-WP-0050-T04. Warden pointer seam: +`ops-warden/wiki/CatalogLaneScaffoldContract.md`, WARDEN-WP-0041. + +Run `python3 scripts/credential-change.py scaffold-lane --input --catalog-manifest --output-dir `. +The catalog manifest is optional for ordinary local preparation and required for +cross-owner fixture acceptance. Both inputs are metadata only; this command has +no execute, credential, network, approval or apply option. jsonschema and PyYAML +are required, as used by the local validation environment. + +`schemas/catalog-lane-scaffold.schema.json` is the closed platform request v1. +Explicit owner/consumer/lane, prospective Kubernetes SA/namespace/role/audience, +exact KV mount/path/field names, namespace-restricted ESO destination, risk for the +entire readable path and required review roles must be supplied. Missing claims +and risk are not inferred. The first shape supports one prospective ESO reader, +audience openbao and a maximum fifteen-minute token TTL. Tenant/dynamic secret, +OIDC/ceremony, wildcard, metadata listing, self-lifecycle and executable fetching +need separate reviewed extensions. No credential values or protected URLs belong +in this input. Unknown fields, malformed/duplicate YAML keys, control characters, +secret markers, invalid calendar dates and mismatched mappings are refused with +safe errors. Warden's unknown workload identity remains unknown and does not +confirm the prospective platform auth binding. + +The generator reuses `credential-change.py`'s `generated_policy_hcl` and +`auth_payload`, preserving one exact data-read path and explicit policies/claims. +It emits six draft review artifacts: a CCR under `credential-change-requests/`, +its policy under `openbao/policies/`, auth payload under `openbao/auth/`, ESO review +manifest under `manifests/`, a non-executed evidence checklist under +`docs/evidence/`, and README. The policy path in the CCR is the eventual owner +repository destination, relative to the staged bundle. Auth payloads are wrapped +in draft metadata; no command executes them. No Secret object or value is emitted. + +CCR status is proposed, review comments empty, bound claims unconfirmed and +frontdoor pending-review/unresolvable. Store/ExternalSecret annotations also say +pending-review. The ExternalSecret target has explicit annotation metadata to +contain source last-applied annotation copying. Evidence is not-executed and +verified=false; it lists metadata/capability/negative/cleanup receipts the owner +must produce later using its reviewed output-suppressing tools. It is a scaffold, +not a successful verification receipt. Nothing is apply-ready authority. + +Generation requires a new output directory outside the platform or other owner +repository, refuses existing paths including symlinks, and never overwrites any +file. Caller-selected staging beneath the system temporary root is supported. +Inputs remain immutable and artifacts are deterministic; explicit dates and +request/catalog SHA256 bind provenance without clock/network reads. + +## Cross-owner fixture + +`examples/catalog-lane-scaffold/platform-request.yaml` and `warden-request.yaml` +use synthetic lane example-workload-kv, consumer example-consumer, prospective +reader example-consumer-eso and CCR-2099-9001. No published workload identity, +existing client or credential is claimed. Warden manifest references the platform +request and eventual CCR destination, includes explicit reviewed date and whole +path risk. Platform checks shared schema/lane/title/consumer/owner/risk and exact +CCR destination; Warden validates its own complete pointer schema. + +Run both owner scaffold commands into separate new temporary directories and +compare the Warden draft catalog with the proposed CCR. Same lane and pointers, +risk/owner/consumer parity, retained unknown identity, unconfirmed platform claims, +unresolvable frontdoor and absence of executable fetch/approval state are required. +The synthetic fixture does not admit an actual lane. Source parity remains distinct +from any founder custody choice, attended dated window or production promotion. diff --git a/docs/evidence/2026-10-04-catalog-lane-scaffold-offline.json b/docs/evidence/2026-10-04-catalog-lane-scaffold-offline.json new file mode 100644 index 0000000..373fee1 --- /dev/null +++ b/docs/evidence/2026-10-04-catalog-lane-scaffold-offline.json @@ -0,0 +1,33 @@ +{ + "status": "passed", + "scope": "actual offline owner CLIs + independently verified deterministic cross-owner fixture; no admission or runtime act", + "platform_stage": "/tmp/p4-cross-platform-tj7_84lv/platform", + "warden_stage": "/tmp/warden-cross-owner-catalog", + "fixture_sha256": "38647d436ccfa8893974c4b88c93d3bd94a9c0a21a2603b54bd84c0195954e32", + "artifact_sha256": { + "README.md": "2f48aa6ef925f62722ca32d38283d746c0c386da751dec3f9a76fba125d24245", + "credential-change-requests/CCR-2099-9001.yaml": "e9db54694b966f186433aaf1f3de6685d8f34be166af9d966950bcca448f151f", + "docs/evidence/example-workload-kv.checklist.json": "7d9a58b160c958d8d46f7029398eef8e70f87f0d9423198fb789bdf8fe3a9f9a", + "manifests/example-workload-kv.draft.yaml": "2d23bee7451735e2a0aa74435c65a0968bd05e35c35625a4087e2f286a2408b5", + "openbao/auth/example-consumer-eso.draft.json": "545a03c95c50b68b0eb127ae5e72d904faf9f68ecfabe26deac27730905b476d", + "openbao/policies/workload-kv-read-example-consumer.hcl": "c2821cd6fe5bb16564366fb12fd37c559048841a41c6f2778ad2dad5901e4df4" + }, + "new_scaffold_tests": 14, + "prior_window_tests": 7, + "unknown_workload_preserved": true, + "prospective_auth_claims_unconfirmed": true, + "frontdoor_pending_unresolvable": true, + "evidence_not_executed": true, + "source_sha256": { + "scripts/catalog_lane_scaffold.py": "96d60cc47aa78728f5ba96ce1fd63033adf23c05641de93ebbdda3902082d2d1", + "scripts/credential-change.py": "d78c493e7c75f3ad0e013745bc13e9f79b9fffc560e5c33ece62e982d800b391", + "schemas/catalog-lane-scaffold.schema.json": "8c2100052a86ebef021398bed662ed8a0fe6970506b785b22cec2398e75adaff", + "examples/catalog-lane-scaffold/platform-request.yaml": "2070fa0f937ee03add01d1e78b9dbdebf72472b3d8fcc8fae9a51436ec60ab8e", + "examples/catalog-lane-scaffold/warden-request.yaml": "38647d436ccfa8893974c4b88c93d3bd94a9c0a21a2603b54bd84c0195954e32", + "tests/test_catalog_lane_scaffold.py": "a4abc80b0a29362296208f7d233b9a206a299d0c47f244929f9851fb543911c6", + "docs/catalog-lane-scaffold.md": "f6b46acf6515fc13a7ce64b74306c9225c9384f73e63bc7b9723232b08ad3c3c" + }, + "task_id": "RPF-WP-0050-T04", + "task_uuid": "6475d924-92c6-504a-b9a1-4f035273f0e1", + "recorded_date": "2026-10-04" +} diff --git a/examples/catalog-lane-scaffold/platform-request.yaml b/examples/catalog-lane-scaffold/platform-request.yaml new file mode 100644 index 0000000..a3bf656 --- /dev/null +++ b/examples/catalog-lane-scaffold/platform-request.yaml @@ -0,0 +1,49 @@ +schema_version: 1 +lane_id: example-workload-kv +ccr_id: CCR-2099-9001 +title: Synthetic exact workload KV read fixture +date: '2026-10-04' +consumer_repo: example-consumer +owner_repo: railiance-platform +requester: + agent: example-consumer + reason: Offline fixture only; no deployed consumer or credential exists +review_approvers: +- platform-owner +- consumer-owner +target: + domain: infotech + tenant: platform + workload: example-consumer + environment: build + purpose: Offline fixture of proposed exact-path metadata +openbao: + mount: platform + kv_path: platform/workloads/example-consumer/config + fields: + - CLIENT_SECRET + policy_name: workload-kv-read-example-consumer + auth: + mount: kubernetes + role: example-consumer-eso + audience: openbao + service_account_names: + - example-consumer-eso + service_account_namespaces: + - example-consumer + ttl: 15m +eso: + store_name: openbao-example-consumer + server: http://openbao.openbao.svc:8200 + namespace: example-consumer + external_secret_name: example-consumer-config + secret_name: example-consumer-config + refresh_interval: 5m + data: + - secret_key: client-secret + property: CLIENT_SECRET +risk: + classification: standard + notes: + - Synthetic prospective lane risk applies to the entire readable path; no actual + credentials. diff --git a/examples/catalog-lane-scaffold/warden-request.yaml b/examples/catalog-lane-scaffold/warden-request.yaml new file mode 100644 index 0000000..b2fb915 --- /dev/null +++ b/examples/catalog-lane-scaffold/warden-request.yaml @@ -0,0 +1,15 @@ +schema_version: 1 +lane_id: example-workload-kv +title: Synthetic exact workload KV read fixture +need_keywords: +- synthetic example workload config +consumer_repo: example-consumer +owner_repo: railiance-platform +workload_ref: + applicability: applicable + unknown_reason: Synthetic fixture has no published deployment identity +risk: standard +wiki_ref: railiance-platform:docs/catalog-lane-scaffold.md +canon_ref: railiance-platform:credential-change-requests/CCR-2099-9001.yaml +reviewed: '2026-10-04' +platform_request_ref: railiance-platform:examples/catalog-lane-scaffold/platform-request.yaml diff --git a/schemas/catalog-lane-scaffold.schema.json b/schemas/catalog-lane-scaffold.schema.json new file mode 100644 index 0000000..20f66fe --- /dev/null +++ b/schemas/catalog-lane-scaffold.schema.json @@ -0,0 +1,344 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "const": 1 + }, + "lane_id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "ccr_id": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^CCR-[0-9]{4}-[0-9]{4}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "date": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "consumer_repo": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "owner_repo": { + "const": "railiance-platform" + }, + "requester": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "agent", + "reason" + ] + }, + "review_approvers": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "tenant": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "workload": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "environment": { + "enum": [ + "build", + "development", + "test", + "staging", + "production" + ] + }, + "purpose": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "domain", + "tenant", + "workload", + "environment", + "purpose" + ] + }, + "openbao": { + "type": "object", + "additionalProperties": false, + "properties": { + "mount": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "kv_path": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9][a-z0-9_-]*(?:/[a-z0-9][a-z0-9_-]*)+$" + }, + "fields": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "policy_name": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^workload-kv-read-[a-z0-9-]+$" + }, + "auth": { + "type": "object", + "additionalProperties": false, + "properties": { + "mount": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "role": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "audience": { + "const": "openbao" + }, + "service_account_names": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + } + }, + "service_account_namespaces": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + } + }, + "ttl": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[1-9][0-9]*[smh]$" + } + }, + "required": [ + "mount", + "role", + "audience", + "service_account_names", + "service_account_namespaces", + "ttl" + ] + } + }, + "required": [ + "mount", + "kv_path", + "fields", + "policy_name", + "auth" + ] + }, + "eso": { + "type": "object", + "additionalProperties": false, + "properties": { + "store_name": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "server": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^https?://[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?$" + }, + "namespace": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "external_secret_name": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "secret_name": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "refresh_interval": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[1-9][0-9]*[smh]$" + }, + "data": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "secret_key": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[a-zA-Z0-9._-]+$" + }, + "property": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "required": [ + "secret_key", + "property" + ] + } + } + }, + "required": [ + "store_name", + "server", + "namespace", + "external_secret_name", + "secret_name", + "refresh_interval", + "data" + ] + }, + "risk": { + "type": "object", + "additionalProperties": false, + "properties": { + "classification": { + "enum": [ + "standard", + "high" + ] + }, + "notes": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + }, + "required": [ + "classification", + "notes" + ] + } + }, + "required": [ + "schema_version", + "lane_id", + "ccr_id", + "title", + "date", + "consumer_repo", + "owner_repo", + "requester", + "review_approvers", + "target", + "openbao", + "eso", + "risk" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Draft exact KV-v2 workload-read lane input v1" +} diff --git a/scripts/catalog_lane_scaffold.py b/scripts/catalog_lane_scaffold.py new file mode 100644 index 0000000..9a71267 --- /dev/null +++ b/scripts/catalog_lane_scaffold.py @@ -0,0 +1,203 @@ +"""Draft-only offline exact-path lane scaffolding; no execution surface.""" +from __future__ import annotations + +from copy import deepcopy +from datetime import date +import hashlib +import json +from pathlib import Path +import re +import sys +import tempfile + +import jsonschema +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] +MAX_INPUT_BYTES = 128 * 1024 +SECRET_MARKERS = ('AGE-SECRET-KEY-1', '-----BEGIN', 'hvs.', 'hvb.', 'hvc.', 'ghp_', 'github_pat_', 'npm_', 'BAO_TOKEN=', 'VAULT_TOKEN=', 'OPENBAO_ROOT_TOKEN=') + + +class UniqueLoader(yaml.SafeLoader): + pass + + +def unique_mapping(loader, node): + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node) + if not isinstance(key, str) or key in result: + raise ValueError('Invalid or duplicate metadata key') + result[key] = loader.construct_object(value_node) + return result + + +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) + + +def read_metadata(path): + with Path(path).open('rb') as stream: + raw = stream.read(MAX_INPUT_BYTES + 1) + if len(raw) > MAX_INPUT_BYTES: + raise ValueError('Metadata input exceeds size limit') + text = raw.decode('utf-8') + if any(marker in text for marker in SECRET_MARKERS) or re.search(r'(? 900: + raise ValueError('Prospective Kubernetes token TTL exceeds fifteen minutes') + eso = data['eso'] + if len(auth['service_account_names']) != 1 or len(auth['service_account_namespaces']) != 1: + raise ValueError('One explicit ESO auth identity is supported') + keys = [item['secret_key'] for item in eso['data']] + properties = [item['property'] for item in eso['data']] + if len(set(keys)) != len(keys) or len(set(properties)) != len(properties) or set(properties) != set(bao['fields']): + raise ValueError('ESO mappings must cover the explicit field set exactly once') + + +def verify_catalog_join(request, catalog): + # Warden validates its own full input; platform checks only the shared seam. + if not isinstance(catalog, dict): + raise ValueError('Catalog pointer manifest must be an object') + for field in ('schema_version', 'lane_id', 'title', 'consumer_repo', 'owner_repo'): + if catalog.get(field) != request[field]: + raise ValueError('Cross-owner fixture identity mismatch') + if catalog.get('risk') != request['risk']['classification']: + raise ValueError('Cross-owner risk mismatch') + if catalog.get('canon_ref') != 'railiance-platform:credential-change-requests/' + request['ccr_id'] + '.yaml': + raise ValueError('Catalog CCR destination pointer mismatch') + ref = catalog.get('platform_request_ref') + if not isinstance(ref, str) or not re.fullmatch(r'railiance-platform:[a-zA-Z0-9_./-]+', ref) or '..' in ref: + raise ValueError('Catalog needs a non-secret owner request pointer') + + +def render_artifacts(data, policy_renderer, auth_renderer, request_digest, catalog_digest=None): + validate_request(data) + data = deepcopy(data) + bao = data['openbao'] + auth = bao['auth'] + lane = data['lane_id'] + policy_file = 'openbao/policies/' + bao['policy_name'] + '.hcl' + ccr = {'id': data['ccr_id'], 'kind': 'credential-change-request', 'schema_version': 1, + 'request_type': 'workload-kv-read', 'title': data['title'], 'status': 'proposed', + 'created': data['date'], 'updated': data['date'], 'requester': data['requester'], + 'review': {'required': True, 'required_approvers': data['review_approvers'], 'comments': []}, + 'target': data['target'], + 'openbao': {'mount': bao['mount'], 'kv_path': bao['kv_path'], 'fields': bao['fields'], + 'policy_name': bao['policy_name'], 'policy_file': policy_file, 'metadata_read': False, + 'auth': {'method': 'kubernetes', 'mount': auth['mount'], 'role': auth['role'], + 'audience': auth['audience'], 'ttl': auth['ttl'], + 'token_max_ttl': auth['ttl'], 'token_explicit_max_ttl': auth['ttl'], + 'token_no_default_policy': True, 'policies': [bao['policy_name']], + 'bound_claims': {'service_account_names': auth['service_account_names'], + 'service_account_namespaces': auth['service_account_namespaces']}, + 'bound_claims_confirmed': False}}, + 'access_frontdoor': {'type': 'external-secrets', 'catalog_id': lane, + 'readiness': 'pending-review', 'resolvable': False}, + 'risk': data['risk'], + 'verification': {'positive': ['Owner-reviewed native exact identity and exact path proof required'], + 'negative': ['Owner-reviewed wrong identity, sibling GET and parent LIST denials required'], + 'activation_conditions': ['Exact CCR approval, confirmed identity, attended custody and native receipt acceptance required']}, + 'lifecycle': {'deactivate': 'Owner-reviewed delivery withdrawal and token revocation; exact rollback receipt required', + 'rotate': 'Separate version-guarded owner approval and contained value delivery required', + 'compromised': 'Owner incident process; no automatic read, reseed or cleanup'}, + 'scaffold': {'draft_only': True, 'consumer_repo': data['consumer_repo'], + 'owner_repo': data['owner_repo'], 'request_sha256': request_digest, + 'catalog_request_sha256': catalog_digest}} + annotation = {'railiance.io/credential-change': data['ccr_id'], 'railiance.io/admission': 'pending-review'} + eso = data['eso'] + store = {'apiVersion': 'external-secrets.io/v1', 'kind': 'ClusterSecretStore', + 'metadata': {'name': eso['store_name'], 'annotations': deepcopy(annotation)}, + 'spec': {'provider': {'vault': {'server': eso['server'], 'path': bao['mount'], 'version': 'v2', + 'auth': {'kubernetes': {'mountPath': auth['mount'], 'role': auth['role'], + 'audiences': [auth['audience']], + 'serviceAccountRef': {'name': auth['service_account_names'][0], + 'namespace': auth['service_account_namespaces'][0]}}}}}, + 'conditions': [{'namespaces': [eso['namespace']]}]}} + secret = {'apiVersion': 'external-secrets.io/v1', 'kind': 'ExternalSecret', + 'metadata': {'name': eso['external_secret_name'], 'namespace': eso['namespace'], 'annotations': deepcopy(annotation)}, + 'spec': {'refreshInterval': eso['refresh_interval'], + 'secretStoreRef': {'kind': 'ClusterSecretStore', 'name': eso['store_name']}, + 'target': {'name': eso['secret_name'], 'creationPolicy': 'Owner', 'deletionPolicy': 'Retain', + 'template': {'metadata': {'annotations': deepcopy(annotation)}}}, + 'data': [{'secretKey': item['secret_key'], + 'remoteRef': {'key': bao['kv_path'][len(bao['mount']) + 1:], 'property': item['property']}} + for item in eso['data']]}} + evidence = {'schema_version': 1, 'lane_id': lane, 'ccr_id': data['ccr_id'], 'status': 'not-executed', + 'draft_only': True, 'verified': False, 'request_sha256': request_digest, + 'required_owner_checks': ['Exact revision/digest and required owner reviews', + 'Identity/auth claims confirmed separately; no inferred workload identity', + 'Metadata/capability readback through reviewed output-suppressing owner tool', + 'No Secret bodies, values or raw subprocess errors in evidence', + 'Native scoped positive/negative, cleanup/revocation and rollback receipts after approval']} + artifacts = {'credential-change-requests/' + data['ccr_id'] + '.yaml': yaml.safe_dump(ccr, sort_keys=False), + policy_file: '# DRAFT ONLY: exact CCR review and confirmed identity required.\n' + policy_renderer(ccr), + 'openbao/auth/' + auth['role'] + '.draft.json': json.dumps({'draft_only': True, 'status': 'proposed', + 'bound_claims_confirmed': False, + 'auth_payload': auth_renderer(ccr)}, indent=2) + '\n', + 'manifests/' + lane + '.draft.yaml': '# DRAFT REVIEW ARTIFACT. Do not apply. No custody/admission supplied.\n' + yaml.safe_dump_all([store, secret], sort_keys=False), + 'docs/evidence/' + lane + '.checklist.json': json.dumps(evidence, indent=2) + '\n', + 'README.md': 'Draft-only review bundle. No admission, approval, confirmed identity, credential value or executed receipt.\n' + 'Review the exact request digest and owner destinations before promoting any file.\n' + 'ESO target metadata is explicit to contain source last-applied annotations.\n'} + return artifacts + + +def scaffold(input_path, output_dir, policy_renderer, auth_renderer, catalog_manifest=None): + data, digest = read_metadata(input_path) + catalog_digest = None + if catalog_manifest is not None: + catalog, catalog_digest = read_metadata(catalog_manifest) + validate_request(data) + verify_catalog_join(data, catalog) + artifacts = render_artifacts(data, policy_renderer, auth_renderer, digest, catalog_digest) + output = Path(output_dir).absolute() + if output.is_symlink() or output.exists() or output.resolve().is_relative_to(REPO_DIR.resolve()): + raise ValueError('Output must be a new staging directory outside the owner repository') + if any((p / '.git').exists() for p in output.resolve().parents if p != Path(tempfile.gettempdir()).resolve()): + raise ValueError('Output staging must not be inside a repository') + output.mkdir(mode=0o700, parents=False) + for relative, content in artifacts.items(): + path = output / relative + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('x', encoding='utf-8') as stream: + stream.write(content) + return artifacts + + +def command_scaffold(args, policy_renderer, auth_renderer): + try: + artifacts = scaffold(args.input, args.output_dir, policy_renderer, auth_renderer, args.catalog_manifest) + except (ValueError, OSError, UnicodeError, RecursionError): + print('Scaffold refused: invalid metadata, mismatched owner pointers, or unsafe staging output.', file=sys.stderr) + return 2 + print('Prepared ' + str(len(artifacts)) + ' draft review artifacts; nothing admitted or executed.') + return 0 diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 38f6682..05d0ba8 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -2240,6 +2240,13 @@ def build_parser() -> argparse.ArgumentParser: ) applier_apply.set_defaults(func=command_applier_apply) + from catalog_lane_scaffold import command_scaffold + scaffold = sub.add_parser("scaffold-lane", help="Generate proposed offline CCR/policy/ESO/evidence drafts in a new staging directory") + scaffold.add_argument("--input", required=True) + scaffold.add_argument("--output-dir", required=True) + scaffold.add_argument("--catalog-manifest") + scaffold.set_defaults(func=lambda args: command_scaffold(args, generated_policy_hcl, auth_payload)) + from provisioning_window import command_window window = sub.add_parser("window-checklist", help="Render an offline attended-window preparation checklist; never execute") window.add_argument("--window-file", required=True) diff --git a/tests/test_catalog_lane_scaffold.py b/tests/test_catalog_lane_scaffold.py new file mode 100644 index 0000000..2d05753 --- /dev/null +++ b/tests/test_catalog_lane_scaffold.py @@ -0,0 +1,150 @@ +import argparse +from copy import deepcopy +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import urllib.request + +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] +SCRIPTS = REPO_DIR / 'scripts' +sys.path.insert(0, str(SCRIPTS)) +import catalog_lane_scaffold as scaffold +SPEC = importlib.util.spec_from_file_location('ccr_scaffold_helpers', SCRIPTS / 'credential-change.py') +ccr = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(ccr) + + +class ScaffoldTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.request = REPO_DIR / 'examples/catalog-lane-scaffold/platform-request.yaml' + self.catalog = REPO_DIR / 'examples/catalog-lane-scaffold/warden-request.yaml' + self.data, self.digest = scaffold.read_metadata(self.request) + + def render(self, data=None): + return scaffold.render_artifacts(data or self.data, ccr.generated_policy_hcl, ccr.auth_payload, self.digest) + + def write_request(self, data): + path = self.root / 'request.yaml' + path.write_text(yaml.safe_dump(data)) + return path + + def test_existing_ccr_validator_accepts_only_proposed_unconfirmed_draft(self): + output = self.root / 'bundle' + scaffold.scaffold(self.request, output, ccr.generated_policy_hcl, ccr.auth_payload, self.catalog) + path = output / 'credential-change-requests/CCR-2099-9001.yaml' + with patch.object(ccr, 'REPO_DIR', output): + data, errors, warnings = ccr.validate_ccr(path) + self.assertEqual(errors, []) + self.assertTrue(any('not confirmed' in w for w in warnings)) + self.assertEqual(data['status'], 'proposed') + self.assertFalse(data['access_frontdoor']['resolvable']) + self.assertFalse(data['openbao']['auth']['bound_claims_confirmed']) + self.assertEqual(data['review']['comments'], []) + self.assertTrue(ccr.applier_readiness_blockers(data)) + self.assertEqual(set(ccr.auth_payload(data)['bound_service_account_names']), {'example-consumer-eso'}) + + def test_policy_and_eso_exact_scope_annotation_containment_and_no_values(self): + artifacts = self.render() + policy = artifacts['openbao/policies/workload-kv-read-example-consumer.hcl'] + self.assertIn('path "platform/data/workloads/example-consumer/config"', policy) + self.assertEqual(policy.count('path "'), 1) + self.assertNotIn('metadata/', policy) + store, secret = list(yaml.safe_load_all(artifacts['manifests/example-workload-kv.draft.yaml'])) + self.assertEqual(store['spec']['conditions'], [{'namespaces': ['example-consumer']}]) + self.assertEqual(store['spec']['provider']['vault']['auth']['kubernetes']['audiences'], ['openbao']) + target = secret['spec']['target'] + self.assertEqual(target['template']['metadata']['annotations']['railiance.io/admission'], 'pending-review') + self.assertNotIn('kubectl.kubernetes.io/last-applied-configuration', json.dumps(target)) + self.assertEqual(secret['spec']['data'], [{'secretKey': 'client-secret', 'remoteRef': {'key': 'workloads/example-consumer/config', 'property': 'CLIENT_SECRET'}}]) + self.assertNotIn('dataFrom', secret['spec']) + self.assertNotIn('Secret', [store['kind'], secret['kind']]) + evidence = json.loads(artifacts['docs/evidence/example-workload-kv.checklist.json']) + self.assertFalse(evidence['verified']) + self.assertEqual(evidence['status'], 'not-executed') + + def test_closed_schema_and_authority_injection_refused(self): + for location, key, value in [('top','status','approved'),('auth','bound_claims_confirmed',True),('top','secret_values',{'CLIENT_SECRET':'not-a-value'})]: + data = deepcopy(self.data) + target = data if location == 'top' else data['openbao']['auth'] + target[key] = value + with self.assertRaises(ValueError): self.render(data) + + def test_missing_identity_and_risk_are_not_inferred(self): + for location, key in [('auth','service_account_names'),('top','risk')]: + data = deepcopy(self.data) + target = data if location == 'top' else data['openbao']['auth'] + del target[key] + with self.assertRaises(ValueError): self.render(data) + + def test_wildcards_traversal_and_hcl_injection_refused(self): + for path in ['platform/workloads/*','platform/../other','platform/workloads/config\n','platform/a/" { capabilities = ["sudo"] }']: + data = deepcopy(self.data);data['openbao']['kv_path'] = path + with self.assertRaises(ValueError): self.render(data) + + def test_wrong_mount_and_overlong_ttl_refused(self): + data = deepcopy(self.data);data['openbao']['mount'] = 'other' + with self.assertRaises(ValueError): self.render(data) + data = deepcopy(self.data);data['openbao']['auth']['ttl'] = '1h' + with self.assertRaises(ValueError): self.render(data) + + def test_field_mapping_omissions_duplicates_and_extra_fields_refused(self): + for mapping in [[], [{'secret_key':'one','property':'OTHER'}], [{'secret_key':'a','property':'CLIENT_SECRET'},{'secret_key':'b','property':'CLIENT_SECRET'}]]: + data = deepcopy(self.data);data['eso']['data'] = mapping + with self.assertRaises(ValueError): self.render(data) + + def test_cross_owner_pointer_fixture_and_unknown_identity_preserved(self): + catalog, _ = scaffold.read_metadata(self.catalog) + scaffold.verify_catalog_join(self.data, catalog) + self.assertIn('unknown_reason', catalog['workload_ref']) + for key,value in [('lane_id','other'),('risk','high'),('canon_ref','railiance-platform:credential-change-requests/OTHER.yaml'),('platform_request_ref','other-owner:request.yaml')]: + bad=deepcopy(catalog);bad[key]=value + with self.assertRaises(ValueError): scaffold.verify_catalog_join(self.data,bad) + + def test_canonical_outputs_deterministic_and_input_immutable(self): + before = self.request.read_bytes();data_before=deepcopy(self.data) + self.assertEqual(self.render(), self.render()) + self.assertEqual(self.data,data_before) + self.assertEqual(self.request.read_bytes(),before) + + def test_collision_and_repo_output_refused_without_overwrite(self): + output=self.root/'occupied';output.mkdir();sentinel=output/'sentinel';sentinel.write_text('keep') + with self.assertRaises(ValueError): scaffold.scaffold(self.request,output,ccr.generated_policy_hcl,ccr.auth_payload) + self.assertEqual(sentinel.read_text(),'keep') + with self.assertRaises(ValueError): scaffold.scaffold(self.request,REPO_DIR/'live-output',ccr.generated_policy_hcl,ccr.auth_payload) + + def test_no_network_or_subprocess_in_generation(self): + with patch.object(urllib.request,'urlopen',side_effect=AssertionError('network forbidden')),patch.object(subprocess,'run',side_effect=AssertionError('execution forbidden')): + artifacts=scaffold.scaffold(self.request,self.root/'isolated',ccr.generated_policy_hcl,ccr.auth_payload,self.catalog) + self.assertEqual(len(artifacts),6) + + def test_duplicate_yaml_and_secret_material_refused_without_echo(self): + for text in ['lane_id: a\nlane_id: b\n', 'title: hvs.CANARY_NEVER_ECHO\n', 'title: "\\x68vs.CANARY_NEVER_ECHO"\n']: + path=self.root/'bad.yaml';path.write_text(text) + args=argparse.Namespace(input=path,output_dir=self.root/'bad-output',catalog_manifest=None) + stream=io.StringIO() + with patch('sys.stderr',stream): self.assertEqual(scaffold.command_scaffold(args,ccr.generated_policy_hcl,ccr.auth_payload),2) + self.assertNotIn('CANARY',stream.getvalue()) + self.assertFalse((self.root/'bad-output').exists()) + + def test_oversized_input_refused(self): + path=self.root/'big.yaml';path.write_text('a'*(scaffold.MAX_INPUT_BYTES+1)) + with self.assertRaises(ValueError): scaffold.read_metadata(path) + + def test_schema_rejects_boolean_version_and_nonexistent_calendar_day(self): + for version,day in [(True,'2026-10-04'),(1,'2026-02-31')]: + data=deepcopy(self.data);data['schema_version']=version;data['date']=day + with self.assertRaises(ValueError): self.render(data) + + +if __name__ == '__main__': unittest.main() diff --git a/workplans/RPF-WP-0050-attended-provisioning-preparation.md b/workplans/RPF-WP-0050-attended-provisioning-preparation.md index 02ff80e..374aff4 100644 --- a/workplans/RPF-WP-0050-attended-provisioning-preparation.md +++ b/workplans/RPF-WP-0050-attended-provisioning-preparation.md @@ -57,7 +57,7 @@ Generate a checklist from all named authoritative roots, verified Hub decision j ```task id: RPF-WP-0050-T04 -status: todo +status: done priority: medium state_hub_task_id: "6475d924-92c6-504a-b9a1-4f035273f0e1" ``` @@ -65,3 +65,7 @@ state_hub_task_id: "6475d924-92c6-504a-b9a1-4f035273f0e1" P4 accepted for platform-side source review and bounded scaffolding after Warden returns its contract. Catalog entry/scaffold never supplies credentials, reviews, admission or live authority. Keep owner acceptance explicit before implementation. 2026-10-03: Platform source review accepts the delivered Warden seam at `ops-warden/wiki/CatalogLaneScaffoldContract.md` (`ac112d8`). T04 is now a ready source implementation task for the platform-owned rendering and schema fixture. Warden T02 may progress independently; no mutual owner-return wait remains. This is source contract acceptance, not credential admission or production authorization. + +2026-10-04 T04 implementation: closed platform metadata schema and `credential-change.py scaffold-lane` authored with deterministic proposed CCR, existing exact-path policy/auth rendering, namespace-restricted draft ESO with annotation containment, and non-executed owner evidence checklist. Shared synthetic platform/Warden pointer fixtures retain unknown workload identity and unconfirmed auth claims. Fourteen focused offline tests pass in isolated prepared source; cross-owner staging fixture acceptance remains before marking T04 done. No admission, credentials, human answer or production execution. + +2026-10-04 T04 acceptance: actual applied-source scaffold tests 14/14 and existing window tests 7/7 pass, with CLI help and diff checks. Both actual owner CLIs produced matching cross-owner synthetic fixture drafts; platform independently verified Warden catalog/model loading, exact lane/owner/consumer/risk/CCR pointers and fixture digest, preserved unknown workload, unconfirmed prospective auth, proposed/unresolvable frontdoor, explicit ESO target annotations and non-executed evidence. Six platform artifacts match the independently generated second platform stage byte-for-byte. Receipt: `docs/evidence/2026-10-04-catalog-lane-scaffold-offline.json`. T04 is done for offline source/scaffold/fixture acceptance only. T02 remains the founder custody-choice wait; T03 remains the Custodian dated-window handoff. No admission, actual credentials, schedule or production execution was performed.