Prepare bounded State Hub preflight signing lane
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
parent
234b1b559f
commit
a46a6d8213
10 changed files with 501 additions and 14 deletions
|
|
@ -32,7 +32,7 @@
|
||||||
| task | RPF-WP-0035-T01 | done | — | workplans/RPF-WP-0035-credential-lane-implementation.md |
|
| 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-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-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-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-T02 | done | — | workplans/RPF-WP-0036-platform-service-assurance.md |
|
||||||
| task | RPF-WP-0036-T03 | wait | — | workplans/RPF-WP-0036-platform-service-assurance.md |
|
| task | RPF-WP-0036-T03 | wait | — | workplans/RPF-WP-0036-platform-service-assurance.md |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -107,3 +107,11 @@ path "platform/data/workloads/policy-nexus/forgejo-source-read" {
|
||||||
path "platform/metadata/workloads/policy-nexus/forgejo-source-read" {
|
path "platform/metadata/workloads/policy-nexus/forgejo-source-read" {
|
||||||
capabilities = ["deny"]
|
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"]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
8cbbe133516466bd70ee412fb2214fbe3d1fe5d455f513186eb149743a30636d
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
path "platform/data/workloads/state-hub/repository-rename-preflight" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
45
openbao/state-hub-preflight/delivery.yaml
Normal file
45
openbao/state-hub-preflight/delivery.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -236,6 +236,8 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
|
||||||
policy_file = require_string(
|
policy_file = require_string(
|
||||||
openbao.get("policy_file"), "openbao.policy_file", errors
|
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)]
|
fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)]
|
||||||
if not fields:
|
if not fields:
|
||||||
errors.append("openbao.fields must contain at least one field")
|
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"}:
|
if method in {"oidc", "kubernetes"}:
|
||||||
require_string(auth.get("mount"), "openbao.auth.mount", errors)
|
require_string(auth.get("mount"), "openbao.auth.mount", errors)
|
||||||
require_string(auth.get("role"), "openbao.auth.role", 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":
|
if method == "oidc":
|
||||||
redirect_uris = require_list(
|
redirect_uris = require_list(
|
||||||
auth.get("allowed_redirect_uris"),
|
auth.get("allowed_redirect_uris"),
|
||||||
|
|
@ -529,14 +539,12 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str:
|
||||||
openbao = ccr["openbao"]
|
openbao = ccr["openbao"]
|
||||||
mount = openbao["mount"]
|
mount = openbao["mount"]
|
||||||
suffix = openbao["kv_path"][len(mount) + 1 :]
|
suffix = openbao["kv_path"][len(mount) + 1 :]
|
||||||
return (
|
body = (f'path "{mount}/data/{suffix}" {{\n'
|
||||||
f'path "{mount}/data/{suffix}" {{\n'
|
' capabilities = ["read"]\n' "}\n")
|
||||||
' capabilities = ["read"]\n'
|
if openbao.get("metadata_read", True):
|
||||||
"}\n\n"
|
body += (f'\npath "{mount}/metadata/{suffix}" {{\n'
|
||||||
f'path "{mount}/metadata/{suffix}" {{\n'
|
' capabilities = ["read"]\n' "}\n")
|
||||||
' capabilities = ["read"]\n'
|
return body
|
||||||
"}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -598,14 +606,17 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
|
||||||
auth = ccr["openbao"]["auth"]
|
auth = ccr["openbao"]["auth"]
|
||||||
if auth["method"] == "kubernetes":
|
if auth["method"] == "kubernetes":
|
||||||
claims = auth["bound_claims"]
|
claims = auth["bound_claims"]
|
||||||
return {
|
payload = {
|
||||||
"bound_service_account_names": claims.get("service_account_names", []),
|
"bound_service_account_names": claims.get("service_account_names", []),
|
||||||
"bound_service_account_namespaces": claims.get(
|
"bound_service_account_namespaces": claims.get("service_account_namespaces", []),
|
||||||
"service_account_namespaces", []
|
|
||||||
),
|
|
||||||
"policies": ",".join(auth["policies"]),
|
"policies": ",".join(auth["policies"]),
|
||||||
"ttl": auth.get("ttl", "15m"),
|
"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] = {
|
payload: dict[str, Any] = {
|
||||||
"role_type": "oidc",
|
"role_type": "oidc",
|
||||||
|
|
|
||||||
214
scripts/state_hub_preflight_lane.py
Normal file
214
scripts/state_hub_preflight_lane.py
Normal file
|
|
@ -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())
|
||||||
97
tests/test_state_hub_preflight_lane.py
Normal file
97
tests/test_state_hub_preflight_lane.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -98,7 +98,7 @@ The unattended adapter remains a separate demand and gets no operator session.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RPF-WP-0035-T04
|
id: RPF-WP-0035-T04
|
||||||
status: wait
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "35a85846-61d5-54ce-8b18-ede45733d53c"
|
state_hub_task_id: "35a85846-61d5-54ce-8b18-ede45733d53c"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue