From a46a6d82138b544b5b10e6f16d9deba24d08f30d Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 5 Sep 2026 11:50:27 +0200 Subject: [PATCH] Prepare bounded State Hub preflight signing lane Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883 --- WORK-RECORDS.md | 2 +- ...2026-0015-state-hub-preflight-signing.yaml | 108 +++++++++ openbao/policies/agent-high-risk-boundary.hcl | 8 + ...ate-hub-preflight-boundary-baseline.sha256 | 1 + ...oad-kv-read-state-hub-rename-preflight.hcl | 3 + openbao/state-hub-preflight/delivery.yaml | 45 ++++ scripts/credential-change.py | 35 ++- scripts/state_hub_preflight_lane.py | 214 ++++++++++++++++++ tests/test_state_hub_preflight_lane.py | 97 ++++++++ ...-WP-0035-credential-lane-implementation.md | 2 +- 10 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 credential-change-requests/CCR-2026-0015-state-hub-preflight-signing.yaml create mode 100644 openbao/policies/inputs/state-hub-preflight-boundary-baseline.sha256 create mode 100644 openbao/policies/workload-kv-read-state-hub-rename-preflight.hcl create mode 100644 openbao/state-hub-preflight/delivery.yaml create mode 100644 scripts/state_hub_preflight_lane.py create mode 100644 tests/test_state_hub_preflight_lane.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f4fafc4..d048850 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -32,7 +32,7 @@ | task | RPF-WP-0035-T01 | done | — | workplans/RPF-WP-0035-credential-lane-implementation.md | | task | RPF-WP-0035-T02 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md | | task | RPF-WP-0035-T03 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md | -| task | RPF-WP-0035-T04 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md | +| task | RPF-WP-0035-T04 | progress | — | workplans/RPF-WP-0035-credential-lane-implementation.md | | task | RPF-WP-0036-T01 | done | — | workplans/RPF-WP-0036-platform-service-assurance.md | | task | RPF-WP-0036-T02 | done | — | workplans/RPF-WP-0036-platform-service-assurance.md | | task | RPF-WP-0036-T03 | wait | — | workplans/RPF-WP-0036-platform-service-assurance.md | diff --git a/credential-change-requests/CCR-2026-0015-state-hub-preflight-signing.yaml b/credential-change-requests/CCR-2026-0015-state-hub-preflight-signing.yaml new file mode 100644 index 0000000..72308ef --- /dev/null +++ b/credential-change-requests/CCR-2026-0015-state-hub-preflight-signing.yaml @@ -0,0 +1,108 @@ +id: CCR-2026-0015 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: State Hub repository-rename preflight signing read lane +status: proposed +created: '2026-09-05' +updated: '2026-09-05' +requester: + agent: codex + reason: User requested RPF-WP-0035-T04. FLEX-WP-0020-T05 still needs signed State + Hub preflight; retirement has not replaced this transitional contract. No repository + rename is authorized. +review: + required: true + required_approvers: + - platform-operator + - state-hub-owner + comments: + - at: '2026-09-05' + reviewer: codex + decision: binding_observed + comment: 'Live primary/railiance01: namespace/release/deployment state-hub, API + SA state-hub, one replica; ESO CRD supports serviceAccountRef.audiences. New + dedicated delivery identity requires live acceptance.' +target: + domain: infotech + tenant: state-hub + workload: state-hub-api + environment: production + purpose: Deliver one HMAC signing key to the transitional State Hub API; no rename + execution, general credential vending, or second identity authority. +openbao: + mount: platform + kv_path: platform/workloads/state-hub/repository-rename-preflight + fields: + - REPOSITORY_RENAME_PREFLIGHT_SECRET + metadata_read: false + policy_name: workload-kv-read-state-hub-rename-preflight + policy_file: openbao/policies/workload-kv-read-state-hub-rename-preflight.hcl + auth: + method: kubernetes + mount: kubernetes + role: state-hub-rename-preflight-eso + bound_claims: + service_account_names: + - state-hub-preflight-eso + service_account_namespaces: + - state-hub + bound_claims_confirmed: false + policies: + - workload-kv-read-state-hub-rename-preflight + ttl: 15m + audience: openbao + token_max_ttl: 15m + token_explicit_max_ttl: 15m + token_no_default_policy: true +access_frontdoor: + type: external-secrets + catalog_id: state-hub-rename-preflight + selector: State Hub API preflight signing delivery only + readiness: pending-review + resolvable: false +delivery: + surface: external-secrets + target: Namespace SecretStore openbao-state-hub-rename-preflight -> ExternalSecret/Secret + state-hub-rename-preflight; required explicit API-only secretKeyRef. Owner creation, + Retain deletion policy. + bootstrap_command: See docs/credential-lane-designs/state-hub-preflight-signing.md; + scripts/state_hub_preflight_lane.py stages the attended protected writer separately + from this read grant. Owner acceptance and the activation runbook remain pending. +risk: + classification: high + notes: + - Signing key permits creation of preflight evidence; raw values and tokens must + never enter outputs, Git, or State Hub. + - All API replicas must be stopped before rotation; no rolling mixed-key population. + A bounded coordination API outage is the fence. + - ESO is already a trusted cluster-wide controller; no additional broad RBAC is + introduced. + - No metadata read, parent list, sibling read or workload write access. +verification: + positive: + - Exact SA/namespace/audience login and native data GET succeed; ESO SecretSynced. + - All API replicas load the accepted version; non-mutating signed preflight and + health pass. + negative: + - Wrong SA, namespace and audience fail login; sibling/metadata/write access denied. + - Coding-agent deny wins over the new read policy; API SA cannot mint delivery tokens. + - Old signed fixture fails after fenced rotation; tamper, expiry, wrong-key and + no-key checks fail closed. + activation_conditions: + - Reviewed source and attended operator identity available. + - Demand remains FLEX-WP-0020-T05; target primary/railiance01 and fixture confirmed. + - CAS-zero protected generation, exact read grant, and API-only delivery verified. + - Controlled-outage fence and recovery exercised before completion. + evidence: [] +lifecycle: + deactivate: Fence API replicas, disable signing binding and ESO role, restart without + signing; retain KV history by explicit retention decision. + rotate: Stop every API replica and verify no matching pods before CAS rotation; + wait ESO, recreate API replicas with one version, verify old token invalidation + and health. + compromised: Stop every API replica immediately; replace key forward with CAS and + restart only after ESO verification. Never restore compromised key material. +state_hub: + workplan_id: RPF-WP-0035 + task_id: RPF-WP-0035-T04 diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index 60c1d63..1fc6ab6 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -107,3 +107,11 @@ path "platform/data/workloads/policy-nexus/forgejo-source-read" { path "platform/metadata/workloads/policy-nexus/forgejo-source-read" { capabilities = ["deny"] } + +# RPF-WP-0035-T04: preflight signing key is API-only. +path "platform/data/workloads/state-hub/repository-rename-preflight" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/state-hub/repository-rename-preflight" { + capabilities = ["deny"] +} diff --git a/openbao/policies/inputs/state-hub-preflight-boundary-baseline.sha256 b/openbao/policies/inputs/state-hub-preflight-boundary-baseline.sha256 new file mode 100644 index 0000000..0bc12bd --- /dev/null +++ b/openbao/policies/inputs/state-hub-preflight-boundary-baseline.sha256 @@ -0,0 +1 @@ +8cbbe133516466bd70ee412fb2214fbe3d1fe5d455f513186eb149743a30636d diff --git a/openbao/policies/workload-kv-read-state-hub-rename-preflight.hcl b/openbao/policies/workload-kv-read-state-hub-rename-preflight.hcl new file mode 100644 index 0000000..8491071 --- /dev/null +++ b/openbao/policies/workload-kv-read-state-hub-rename-preflight.hcl @@ -0,0 +1,3 @@ +path "platform/data/workloads/state-hub/repository-rename-preflight" { + capabilities = ["read"] +} diff --git a/openbao/state-hub-preflight/delivery.yaml b/openbao/state-hub-preflight/delivery.yaml new file mode 100644 index 0000000..81fc654 --- /dev/null +++ b/openbao/state-hub-preflight/delivery.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: state-hub-preflight-eso + namespace: state-hub +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: openbao-state-hub-rename-preflight + namespace: state-hub +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: state-hub-rename-preflight-eso + serviceAccountRef: + name: state-hub-preflight-eso + audiences: [openbao] +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: state-hub-rename-preflight + namespace: state-hub +spec: + refreshInterval: 1m + secretStoreRef: + name: openbao-state-hub-rename-preflight + kind: SecretStore + target: + name: state-hub-rename-preflight + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: REPOSITORY_RENAME_PREFLIGHT_SECRET + remoteRef: + key: workloads/state-hub/repository-rename-preflight + property: REPOSITORY_RENAME_PREFLIGHT_SECRET diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 1efd76f..39a8247 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -236,6 +236,8 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: policy_file = require_string( openbao.get("policy_file"), "openbao.policy_file", errors ) + if "metadata_read" in openbao and not isinstance(openbao["metadata_read"], bool): + errors.append("openbao.metadata_read must be boolean") fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)] if not fields: errors.append("openbao.fields must contain at least one field") @@ -264,6 +266,14 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: if method in {"oidc", "kubernetes"}: require_string(auth.get("mount"), "openbao.auth.mount", errors) require_string(auth.get("role"), "openbao.auth.role", errors) + if method == "kubernetes": + if "audience" in auth: + require_string(auth["audience"], "openbao.auth.audience", errors) + for key in ("token_max_ttl", "token_explicit_max_ttl"): + if key in auth and (not isinstance(auth[key], str) or not TTL_RE.match(auth[key])): + errors.append(f"openbao.auth.{key} must be a TTL string") + if "token_no_default_policy" in auth and not isinstance(auth["token_no_default_policy"], bool): + errors.append("openbao.auth.token_no_default_policy must be boolean") if method == "oidc": redirect_uris = require_list( auth.get("allowed_redirect_uris"), @@ -529,14 +539,12 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str: openbao = ccr["openbao"] mount = openbao["mount"] suffix = openbao["kv_path"][len(mount) + 1 :] - return ( - f'path "{mount}/data/{suffix}" {{\n' - ' capabilities = ["read"]\n' - "}\n\n" - f'path "{mount}/metadata/{suffix}" {{\n' - ' capabilities = ["read"]\n' - "}\n" - ) + body = (f'path "{mount}/data/{suffix}" {{\n' + ' capabilities = ["read"]\n' "}\n") + if openbao.get("metadata_read", True): + body += (f'\npath "{mount}/metadata/{suffix}" {{\n' + ' capabilities = ["read"]\n' "}\n") + return body @@ -598,14 +606,17 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]: auth = ccr["openbao"]["auth"] if auth["method"] == "kubernetes": claims = auth["bound_claims"] - return { + payload = { "bound_service_account_names": claims.get("service_account_names", []), - "bound_service_account_namespaces": claims.get( - "service_account_namespaces", [] - ), + "bound_service_account_namespaces": claims.get("service_account_namespaces", []), "policies": ",".join(auth["policies"]), "ttl": auth.get("ttl", "15m"), } + for key in ("audience", "token_max_ttl", "token_explicit_max_ttl", "token_no_default_policy"): + if key in auth: + payload[key] = auth[key] + return payload + payload: dict[str, Any] = { "role_type": "oidc", diff --git a/scripts/state_hub_preflight_lane.py b/scripts/state_hub_preflight_lane.py new file mode 100644 index 0000000..6a0a02d --- /dev/null +++ b/scripts/state_hub_preflight_lane.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Attended, silent signing-key writer. Never a workload credential front door.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +CCR = ROOT / 'credential-change-requests/CCR-2026-0015-state-hub-preflight-signing.yaml' +KV = 'platform/workloads/state-hub/repository-rename-preflight' +FIELD = 'REPOSITORY_RENAME_PREFLIGHT_SECRET' +POLICY = 'workload-kv-read-state-hub-rename-preflight' +ROLE = 'state-hub-rename-preflight-eso' +SA = 'state-hub-preflight-eso' + + +class LaneError(Exception): + """Only fixed, non-secret diagnostics may leave the envelope.""" + + +def command(argv, *, payload=None, env=None, allow_failure=False): + result = subprocess.run(argv, input=None if payload is None else json.dumps(payload).encode(), + capture_output=True, env=env, timeout=60) + if result.returncode and not allow_failure: + raise LaneError('command_failed') + return result + + +def bao(args, *, payload=None, token=None, allow_failure=False): + env = os.environ.copy() + if token is not None: + env['BAO_TOKEN'] = token + env['VAULT_TOKEN'] = token + result = command(['bao', *args], payload=payload, env=env, allow_failure=allow_failure) + return result + + +def data(result): + return json.loads(result.stdout) + + +def approved_contract(): + spec = importlib.util.spec_from_file_location('credential_change', ROOT / 'scripts/credential-change.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + ccr, errors, _ = module.validate_ccr(CCR) + if errors or ccr['status'] not in {'approved', 'applied', 'verified', 'active'}: + raise LaneError('approved_ccr_required') + auth = ccr['openbao']['auth'] + expected = {'bound_service_account_names': [SA], 'bound_service_account_namespaces': ['state-hub'], + 'policies': POLICY, 'ttl': '15m', 'audience': 'openbao', + 'token_max_ttl': '15m', 'token_explicit_max_ttl': '15m', 'token_no_default_policy': True} + if (not auth['bound_claims_confirmed'] or ccr['openbao']['kv_path'] != KV + or ccr['openbao']['fields'] != [FIELD] or auth['role'] != ROLE + or auth['mount'] != 'kubernetes' or module.auth_payload(ccr) != expected + or ccr['openbao'].get('metadata_read') is not False): + raise LaneError('exact_contract_required') + policy = module.generated_policy_hcl(ccr) + if (ROOT / ccr['openbao']['policy_file']).read_text() != policy: + raise LaneError('policy_source_mismatch') + return expected, policy + + +def assert_fenced(kube): + deployment = data(command(kube + ['-n', 'state-hub', 'get', 'deployment', 'state-hub', '-o', 'json'])) + pods = data(command(kube + ['-n', 'state-hub', 'get', 'pods', '-l', 'app=state-hub', '-o', 'json'])) + hpas = data(command(kube + ['-n', 'state-hub', 'get', 'hpa', '-o', 'json'])) + if deployment['spec'].get('replicas', 1) != 0 or pods['items'] or hpas['items']: + raise LaneError('all_api_replicas_must_be_stopped_without_autoscaler') + + +def revoke(token): + bao(['write', 'auth/token/revoke', '-'], payload={'token': token}) + + +def capabilities(token, paths): + return data(bao(['write', '-format=json', 'sys/capabilities', '-'], + payload={'paths': paths, 'token': token}))['data'] + + +def verify_access(kube, receipt): + jwt = command(kube + ['-n', 'state-hub', 'create', 'token', SA, + '--audience=openbao', '--duration=10m']).stdout.decode().strip() + auth = data(bao(['write', '-format=json', 'auth/kubernetes/login', '-'], + payload={'role': ROLE, 'jwt': jwt}))['auth'] + token = auth['client_token'] + try: + if auth['token_policies'] != [POLICY] or auth['lease_duration'] > 900: + raise LaneError('effective_policy_or_ttl_mismatch') + paths = [KV.replace('platform/', 'platform/data/', 1), + KV.replace('platform/', 'platform/metadata/', 1), + 'platform/data/workloads/state-hub/forge-derivation', + 'platform/metadata/workloads/state-hub'] + caps = capabilities(token, paths) + if caps[paths[0]] != ['read'] or any(caps[p] != ['deny'] for p in paths[1:]): + raise LaneError('scope_negative_check_failed') + # Native GET, no secret value emitted or retained in receipt. + value = data(bao(['read', '-format=json', paths[0]], token=token))['data']['data'][FIELD] + if len(value) != 64 or any(c not in '0123456789abcdef' for c in value): + raise LaneError('invalid_key_shape') + receipt['exact_read_and_scope_denials'] = True + finally: + revoke(token) + for label, namespace, service_account, audience in [ + ('wrong_sa', 'state-hub', 'default', 'openbao'), + ('wrong_namespace', 'default', SA, 'openbao'), + ('wrong_audience', 'state-hub', SA, 'not-openbao'), + ]: + temporary = label == 'wrong_namespace' + if temporary: + # Exclusive create fails if an unrelated identity already exists. + command(kube + ['-n', namespace, 'create', 'serviceaccount', service_account]) + try: + jwt = command(kube + ['-n', namespace, 'create', 'token', service_account, + '--audience=' + audience, '--duration=10m']).stdout.decode().strip() + result = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], + payload={'role': ROLE, 'jwt': jwt}, allow_failure=True) + if result.returncode == 0: + revoke(data(result)['auth']['client_token']) + raise LaneError('negative_login_unexpectedly_succeeded') + if b'403' not in result.stderr and b'400' not in result.stderr: + raise LaneError('negative_login_inconclusive') + receipt[label] = True + finally: + if temporary: + command(kube + ['-n', namespace, 'delete', 'serviceaccount', service_account]) + agent = data(bao(['read', '-format=json', 'auth/approle/role/coding-agent-railiance-platform']))['data'] + if 'agent-high-risk-boundary' not in agent['token_policies']: + raise LaneError('coding_agent_boundary_missing') + child = data(bao(['token', 'create', '-format=json', '-policy=' + POLICY, + '-policy=agent-high-risk-boundary', '-no-default-policy', '-ttl=60s']))['auth']['client_token'] + try: + caps = capabilities(child, paths[:2]) + if any(caps[p] != ['deny'] for p in paths[:2]): + raise LaneError('coding_agent_deny_failed') + receipt['coding_agent_deny_wins'] = True + finally: + revoke(child) + + +def run(args, receipt): + role, policy = approved_contract() + identity = data(bao(['token', 'lookup', '-format=json']))['data'] + if 'platform-admin' not in identity['policies'] or 'root' in identity['policies']: + raise LaneError('attended_platform_admin_required') + kube = ['kubectl', '--kubeconfig', args.kubeconfig] + if args.action == 'verify': + verify_access(kube, receipt) + receipt['status'] = 'custody_verified_pending_eso_and_api_acceptance' + return + if args.action == 'provision': + if args.expected_version != 0: + raise LaneError('bootstrap_requires_cas_zero') + # Refuse drift rather than overwriting another operator's policy. + boundary = ROOT / 'openbao/policies/agent-high-risk-boundary.hcl' + current = data(bao(['read', '-format=json', 'sys/policies/acl/agent-high-risk-boundary']))['data']['policy'] + baseline = (ROOT / 'openbao/policies/inputs/state-hub-preflight-boundary-baseline.sha256').read_text().strip() + if current != boundary.read_text() and hashlib.sha256(current.encode()).hexdigest() != baseline: + raise LaneError('boundary_policy_drift') + bao(['write', 'sys/policies/acl/agent-high-risk-boundary', '-'], payload={'policy': boundary.read_text()}) + existing = bao(['read', '-format=json', 'auth/kubernetes/role/' + ROLE], allow_failure=True) + if existing.returncode == 0: + raise LaneError('role_already_exists_review_partial_apply') + if b'No value found' not in existing.stderr and b'404' not in existing.stderr: + raise LaneError('role_absence_not_proven') + bao(['write', 'sys/policies/acl/' + POLICY, '-'], payload={'policy': policy}) + bao(['write', 'auth/kubernetes/role/' + ROLE, '-'], payload=role) + command(kube + ['apply', '-f', str(ROOT / 'openbao/state-hub-preflight/delivery.yaml')]) + else: + if args.expected_version < 1: + raise LaneError('rotation_requires_current_version') + assert_fenced(kube) + # Never read or import an old key; protected CSPRNG generation and CAS only. + result = data(bao(['write', '-format=json', KV.replace('platform/', 'platform/data/', 1), '-'], + payload={'options': {'cas': args.expected_version}, 'data': {FIELD: secrets.token_hex(32)}})) + receipt['kv_version'] = result['data']['version'] + receipt['key_generation'] = 'CSPRNG-32-bytes-CAS' + verify_access(kube, receipt) + receipt['status'] = 'custody_verified_pending_eso_and_api_acceptance' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['provision', 'rotate', 'verify']) + parser.add_argument('--expected-version', required=True, type=int) + parser.add_argument('--kubeconfig', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--confirm', required=True) + args = parser.parse_args() + receipt = {'schema': 'platform.statehub-preflight-custody.v1', 'status': 'failed', 'action': args.action} + # Exclusive creation before mutations; no symlinks or overwriting old evidence. + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + if args.confirm != 'APPLY CCR-2026-0015': + raise LaneError('confirmation_mismatch') + run(args, receipt) + except Exception as error: + receipt['error'] = str(error) if isinstance(error, LaneError) else 'internal_error' + finally: + with os.fdopen(fd, 'w') as out: + json.dump(receipt, out, indent=2) + out.write('\n') + return 0 if receipt['status'] != 'failed' else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_state_hub_preflight_lane.py b/tests/test_state_hub_preflight_lane.py new file mode 100644 index 0000000..196e7ee --- /dev/null +++ b/tests/test_state_hub_preflight_lane.py @@ -0,0 +1,97 @@ +from __future__ import annotations +import copy +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +lane = load('preflight_lane', 'state_hub_preflight_lane.py') +cc = load('credential_change_preflight', 'credential-change.py') + +class SigningLaneTests(unittest.TestCase): + def setUp(self): + self.ccr = cc.load_yaml(lane.CCR) + + def test_data_only_policy_and_bounded_auth_survive_plan_generation(self): + self.assertNotIn('/metadata/', cc.generated_policy_hcl(self.ccr)) + auth = cc.auth_payload(self.ccr) + self.assertEqual(auth['audience'], 'openbao') + self.assertEqual(auth['token_explicit_max_ttl'], '15m') + self.assertEqual(auth['token_max_ttl'], '15m') + self.assertIs(auth['token_no_default_policy'], True) + legacy = copy.deepcopy(self.ccr) + del legacy['openbao']['metadata_read'] + self.assertIn('/metadata/', cc.generated_policy_hcl(legacy)) + + def test_malformed_security_options_rejected(self): + for section, key, value in [('openbao', 'metadata_read', 'false'), + ('auth', 'audience', ''), + ('auth', 'token_max_ttl', 900), + ('auth', 'token_no_default_policy', 'true')]: + ccr = copy.deepcopy(self.ccr) + target = ccr['openbao'] if section == 'openbao' else ccr['openbao']['auth'] + target[key] = value + errors = [] + cc.validate_workload_kv_read(ccr, errors, []) + self.assertTrue(any(key in error for error in errors), errors) + + def test_proposed_ccr_cannot_reach_writer(self): + proposed = copy.deepcopy(self.ccr) + proposed['status'] = 'proposed' + with patch.object(lane.importlib.util, 'module_from_spec', return_value=cc), \ + patch.object(cc, 'validate_ccr', return_value=(proposed, [], [])): + # Module execution may replace patched methods; direct temporary file + # exercises the actual loader instead. + pass + import tempfile, yaml + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp) / 'ccr.yaml' + file.write_text(yaml.safe_dump(proposed)) + with patch.object(lane, 'CCR', file): + with self.assertRaisesRegex(lane.LaneError, 'approved_ccr_required'): + lane.approved_contract() + + def test_fence_rejects_running_terminating_or_autoscaled_api(self): + for replicas, pods, hpas in [(1, [], []), (0, [{'metadata': {'deletionTimestamp': 'now'}}], []), + (0, [], [{}])]: + responses = [SimpleNamespace(stdout=json.dumps(x).encode()) for x in + [{'spec': {'replicas': replicas}}, {'items': pods}, {'items': hpas}]] + with patch.object(lane, 'command', side_effect=responses): + with self.assertRaises(lane.LaneError): + lane.assert_fenced(['kubectl']) + + def test_rotation_fence_failure_precedes_key_generation(self): + args = SimpleNamespace(action='rotate', expected_version=1, kubeconfig='/fixture') + result = SimpleNamespace(stdout=json.dumps({'data': {'policies': ['platform-admin']}}).encode()) + with patch.object(lane, 'approved_contract', return_value=({}, '')), \ + patch.object(lane, 'bao', return_value=result), \ + patch.object(lane, 'assert_fenced', side_effect=lane.LaneError('fence')), \ + patch.object(lane.secrets, 'token_hex') as generate: + with self.assertRaises(lane.LaneError): + lane.run(args, {}) + generate.assert_not_called() + + def test_unapproved_key_format_or_policy_does_not_pass_contract(self): + import tempfile, yaml + ccr = copy.deepcopy(self.ccr) + ccr['status'] = 'approved' + ccr['openbao']['auth']['bound_claims_confirmed'] = True + ccr['openbao']['auth']['audience'] = 'kubernetes' + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp) / 'ccr.yaml'; file.write_text(yaml.safe_dump(ccr)) + with patch.object(lane, 'CCR', file): + with self.assertRaisesRegex(lane.LaneError, 'exact_contract_required'): + lane.approved_contract() + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index 906f366..1081f4a 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -98,7 +98,7 @@ The unattended adapter remains a separate demand and gets no operator session. ```task id: RPF-WP-0035-T04 -status: wait +status: progress priority: medium state_hub_task_id: "35a85846-61d5-54ce-8b18-ede45733d53c" ```