Mint a separate MAS workload key in the attended gate instead of copying the supplier key.
All checks were successful
CCR intake gate / gate (push) Successful in 2m14s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The lane design forbids copying the founder-held supplier key onto the
workload path. The gate now mints a second key on the MAS application, proves
it, writes it CAS=0 over HTTP, and deletes it if a later step fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4541@bnt-lap001
Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8
This commit is contained in:
codex 2026-11-02 23:41:20 +01:00
parent e9a2f237b3
commit fbc4830aa0
5 changed files with 356 additions and 42 deletions

View file

@ -54,6 +54,10 @@ and is not created here.
denials still require this application's own key.
5. Provision the pair atomically with KV CAS=0 under the pair-writer policy
extended to this one path. The single-field paste desk is unsuitable.
The workload pair is a second API key minted on the same IAM application
inside the attended gate (`mas_evidence_publisher_gate.py
--mint-workload`). It is never a copy of the supplier key. The gate
deletes the minted key if the proof or the write fails.
6. Record positive reader proof and negative denials, then set delivery
admission. Export the admitted CCR and pin its SHA256 before creating
`/etc/mas-backup/primary.env`.

View file

@ -1,15 +1,26 @@
#!/usr/bin/env node
/** One attended Console login. The helper compares and corrects the MAS IAM rule. */
/**
* One attended Console login.
*
* Default: the helper compares and corrects the MAS IAM rule.
* --mint-workload: start the attended OpenBao platform-admin login with the
* publisher gate as its child. The console session is read and sent only
* after the gate reports ready, so the OIDC login cannot age it out.
*/
import {spawn} from 'node:child_process';
import {readFileSync} from 'node:fs';
import {existsSync, readFileSync, rmSync} from 'node:fs';
import {homedir} from 'node:os';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {parseArgs} from 'node:util';
import {createInterface} from 'node:readline/promises';
const {values} = parseArgs({options: {'playwright-module': {type: 'string'}}});
const {values} = parseArgs({options: {'playwright-module': {type: 'string'}, 'mint-workload': {type: 'boolean'}}});
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const grant = join(root, 'scripts', 'mas_evidence_iam_grant.py');
const gate = join(root, 'scripts', 'mas_evidence_publisher_gate.py');
const ready = join(homedir(), '.local/state/railiance/receipts/mas-evidence-mint-ready.json');
const READY_TIMEOUT_MS = 10 * 60 * 1000;
let browser;
let child;
@ -45,6 +56,46 @@ function runGrant(auth) {
});
}
function runMint(page) {
rmSync(ready, {force: true});
return new Promise((resolveResult, reject) => {
const env = {...process.env};
env.BAO_ADDR = env.BAO_ADDR || 'http://127.0.0.1:18200';
env.VAULT_ADDR = env.VAULT_ADDR || env.BAO_ADDR;
child = spawn('warden', [
'access', 'openbao-platform-admin-login', '--exec', '--', 'python3', gate, '--mint-workload',
], {stdio: ['pipe', 'inherit', 'inherit'], env});
let sent = false;
const started = Date.now();
const timer = setInterval(async () => {
if (sent) return;
if (Date.now() - started > READY_TIMEOUT_MS) {
sent = true;
clearInterval(timer);
child?.stdin.end('');
return;
}
if (!existsSync(ready)) return;
sent = true;
clearInterval(timer);
try {
child?.stdin.end(JSON.stringify(await sessionAuth(page)));
} catch {
child?.stdin.end('');
}
}, 1000);
child.on('error', () => {
clearInterval(timer);
reject(new Error('warden_child_failed'));
});
child.on('close', (code) => {
clearInterval(timer);
child = null;
resolveResult({exit_code: code, output: `warden exit ${code}; receipts under ~/.local/state/railiance/receipts\n`});
});
});
}
try {
const modulePath = resolve(values['playwright-module'] || '');
const pkg = JSON.parse(readFileSync(join(dirname(modulePath), 'package.json'), 'utf8'));
@ -61,7 +112,7 @@ try {
console.log('Console opened. Sign in, then press Enter. The session token stays in this process.');
await reader.question('');
reader.close();
const outcome = await runGrant(await sessionAuth(page));
const outcome = values['mint-workload'] ? await runMint(page) : await runGrant(await sessionAuth(page));
process.stdout.write(outcome.output.endsWith('\n') ? outcome.output : `${outcome.output}\n`);
process.exitCode = outcome.exit_code === 0 ? 0 : 1;
try {

View file

@ -1,8 +1,15 @@
#!/usr/bin/env python3
"""Apply the MAS publisher boundary and write its one CAS=0 pair.
"""Mint the MAS workload key, prove its boundary, and write its one CAS=0 pair.
Run only as `--mint-workload` inside the attended OpenBao platform-admin login,
started by `mas_evidence_iam_session.mjs --mint-workload`. The founder-held
supplier key at SUPPLIER is not read or copied: the workload pair is a second
API key on the same IAM application, so it can be revoked on its own. A
temporary Scaleway Console session arrives on stdin after the gate signals
readiness. If anything fails after the mint, the minted key is deleted.
Stdout stays empty. Receipts record statement ids, HTTP statuses, versions,
and field names. Values are never written to a receipt or to stderr.
and field names. Values are never written to a receipt, a file, or stderr.
"""
from __future__ import annotations
@ -13,7 +20,7 @@ import pwd
import re
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
@ -45,6 +52,13 @@ STATEMENT_LIMIT = 10
SUPPLIER = "platform/suppliers/scaleway/mas-evidence-backup-primary"
WORKLOAD = "platform/workloads/mas/backup-primary"
PREFERRED_PROJECT = "reef-storage"
PROJECT_ID = "e1a0dd0e-04b8-4ea9-8b30-d53f8c35c688"
POLICY_NAME = "mas-evidence-backup-primary"
WORKLOAD_KEY_DESCRIPTION = "MAS evidence workload pair; RPF-WP-0070; node delivery"
READY_NAME = "mas-evidence-mint-ready.json"
MINT_NAME = "mas-evidence-workload-mint.json"
PROOF_ATTEMPTS = 7
PROOF_DELAY_SECONDS = 10
BAO_ADDR = "http://127.0.0.1:18200"
_PROJECT_ID = re.compile(
r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z"
@ -540,24 +554,27 @@ class _Transport:
return None
def put_cas0(self, path, body):
handle = tempfile.NamedTemporaryFile(
"w", prefix="mas-pair-", dir=RECEIPT_DIR, delete=False
)
name = Path(handle.name)
try:
json.dump(body["data"], handle)
handle.flush()
os.fchmod(handle.fileno(), 0o600)
handle.close()
proc = _bao(
["kv", "put", "-format=json", "-cas=0", logical_path(path), f"@{name}"],
token=self.token,
)
finally:
name.unlink(missing_ok=True)
if proc.returncode:
"""Write over the HTTP API. The value never touches a file."""
if logical_path(path) != WORKLOAD or body.get("options") != {"cas": 0}:
raise GateError("pair_write_failed")
return version_from_put(json.loads(proc.stdout))
request = urllib.request.Request(
BAO_ADDR + "/v1/" + path,
data=json.dumps(body).encode(),
method="POST",
headers={"X-Vault-Token": self.token, "Content-Type": "application/json"},
)
opener = urllib.request.build_opener(_NoRedirect)
try:
with opener.open(request, timeout=20) as response:
raw = response.read(65537)
except GateError:
raise
except Exception:
raise GateError("pair_write_failed") from None
try:
return version_from_put(json.loads(raw))
except json.JSONDecodeError:
raise GateError("pair_write_failed") from None
def revoke(self):
_bao(["token", "revoke", "-self"], token=self.token)
@ -627,9 +644,6 @@ def write_pair(pair):
existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD])
if existing.returncode == 0:
raise GateError("existing_custody_requires_rotation_review")
written = _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)])
if written.returncode:
raise GateError("writer_policy_install_failed")
auth = _request_writer_auth()
token = auth["client_token"]
try:
@ -657,31 +671,148 @@ def write_pair(pair):
return summary
def main():
def _app_keys(api):
value = api(
"GET",
"/iam/v1alpha1/api-keys?application_id=" + APPLICATION_ID + "&page_size=100",
)
keys = value.get("api_keys") if isinstance(value, dict) else None
if not isinstance(keys, list) or value.get("total_count") != len(keys):
raise GateError("key_inventory_rejected")
return [item for item in keys if item.get("application_id") == APPLICATION_ID]
def _key_view(keys):
return {
"count": len(keys),
"descriptions": sorted(str(item.get("description") or "") for item in keys),
"default_project_ids": sorted({str(item.get("default_project_id")) for item in keys}),
}
def mint_workload_pair(api, prove, write, sleep=time.sleep):
"""Mint one more key on the MAS application, prove it, then store it CAS=0.
`api` is the console transport. `prove(pair, project)` and `write(pair)`
are the S3 proof and the OpenBao write. The minted key is deleted when a
later step fails, and the receipt says whether that deletion happened.
"""
receipt = {"application_id": APPLICATION_ID, "values_recorded": False}
app = api("GET", "/iam/v1alpha1/applications/" + APPLICATION_ID)
if app.get("name") != POLICY_NAME:
raise GateError("application_binding_refused")
before = _app_keys(api)
receipt["keys_before"] = _key_view(before)
if len(before) != 1 or before[0].get("default_project_id") != PROJECT_ID:
raise GateError("supplier_key_state_requires_review", receipt["keys_before"])
issued = api("POST", "/iam/v1alpha1/api-keys", {
"application_id": APPLICATION_ID,
"default_project_id": PROJECT_ID,
"description": WORKLOAD_KEY_DESCRIPTION,
})
access = issued.get("access_key") if isinstance(issued, dict) else None
try:
if issued.get("application_id") != APPLICATION_ID:
raise GateError("issued_key_binding_refused")
pair = {
"AWS_ACCESS_KEY_ID": access,
"AWS_SECRET_ACCESS_KEY": issued.get("secret_key"),
}
if any(not isinstance(value, str) or not value for value in pair.values()):
raise GateError("issued_key_incomplete")
receipt["minted_at"] = issued.get("created_at")
attempts = 0
while True:
attempts += 1
try:
prove(pair, PROJECT_ID)
break
except GateError as exc:
if exc.code != "publisher_denials_failed" or attempts >= PROOF_ATTEMPTS:
raise
sleep(PROOF_DELAY_SECONDS)
receipt["proof_attempts"] = attempts
receipt["pair"] = write(pair)
except Exception as exc:
deleted = False
if isinstance(access, str) and access:
try:
api("DELETE", "/iam/v1alpha1/api-keys/" + access)
deleted = True
except Exception:
deleted = False
code = exc.code if isinstance(exc, GateError) else "contained_gate_failure"
detail = dict(exc.detail or {}) if isinstance(exc, GateError) and isinstance(exc.detail, dict) else {}
detail["minted_key_deleted"] = deleted
raise GateError(code, detail) from None
receipt["keys_after"] = _key_view(_app_keys(api))
receipt["status"] = "passed"
return receipt
def _console_api(raw):
import importlib.util
if len(raw) > 65536:
raise GateError("console_auth_rejected")
try:
auth = json.loads(raw)
except json.JSONDecodeError:
raise GateError("console_auth_rejected") from None
spec = importlib.util.spec_from_file_location(
"application_recovery_identities", ROOT / "scripts/application_recovery_identities.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
transport = module.Transport(auth)
except module.Refused:
raise GateError("console_auth_rejected") from None
def api(method, path, data=None):
try:
return transport(method, path, data)
except module.ProviderError as exc:
raise GateError("provider_http_error", {"method": method, "http_status": exc.status}) from None
except module.Refused as exc:
raise GateError("provider_refused", {"reason": str(exc)}) from None
return api
def _preflight():
existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD])
if existing.returncode == 0:
raise GateError("existing_custody_requires_rotation_review")
if _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)]).returncode:
raise GateError("writer_policy_install_failed")
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
sys.stdout = open(os.devnull, "w")
sys.stderr = open(os.devnull, "w")
receipt_path = RECEIPT_DIR / "mas-evidence-publisher-gate.json"
try:
if args != ["--mint-workload"]:
raise GateError("usage_refused")
_ensure_boto()
RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True)
supplier = supplier_pair(_kv_data(SUPPLIER), SUPPLIER)
backup_path = "platform/workloads/railiance/backup/object-storage"
bootstrap_path = "platform/workloads/railiance/scaleway/bootstrap"
backup = _kv_data(backup_path)
bootstrap = _kv_data(bootstrap_path)
reader = supplier_pair(backup, backup_path)
writer = supplier_pair(bootstrap, bootstrap_path)
apply_live_policy(reader, writer)
prove_denials(supplier, bootstrap.get("DEFAULT_PROJECT_ID", ""))
write_pair(supplier)
(RECEIPT_DIR / MINT_NAME).unlink(missing_ok=True)
_preflight()
_write_json(RECEIPT_DIR / READY_NAME, {"status": "waiting_for_console", "values_recorded": False})
api = _console_api(sys.stdin.read(65537))
receipt = mint_workload_pair(api, prove_denials, write_pair)
_write_json(RECEIPT_DIR / MINT_NAME, receipt)
except GateError as exc:
receipt = {"status": "failed", "code": exc.code, "values_recorded": False}
if exc.detail:
receipt["detail"] = exc.detail
_write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", receipt)
_write_json(receipt_path, receipt)
return 71
except Exception as exc:
_write_json(
RECEIPT_DIR / "mas-evidence-publisher-gate.json",
receipt_path,
{
"status": "failed",
"code": "contained_gate_failure",
@ -690,7 +821,9 @@ def main():
},
)
return 72
_write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", {"status": "passed", "values_recorded": False})
finally:
(RECEIPT_DIR / READY_NAME).unlink(missing_ok=True)
_write_json(receipt_path, {"status": "passed", "values_recorded": False})
return 0

View file

@ -249,3 +249,113 @@ def test_pair_custody_preview_includes_mas_and_the_writer_policy_names_it():
text = (ROOT / "openbao/policies/backup-pair-writer.hcl").read_text(encoding="utf-8")
assert 'path "platform/data/workloads/mas/backup-primary"' in text
assert "read" not in text.split('path "platform/data/workloads/mas/backup-primary"')[1].split("path ")[0]
class _FakeConsole:
"""Scaleway IAM stand-in. Records calls; never returns the supplier secret."""
def __init__(self, keys=None, issued=None, name="mas-evidence-backup-primary"):
self.name = name
self.keys = keys if keys is not None else [
{"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID,
"description": "supplier"}
]
self.issued = issued if issued is not None else {
"application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED",
"secret_key": "minted-secret", "created_at": "2026-11-02T20:00:00Z",
}
self.calls = []
def __call__(self, method, path, data=None):
self.calls.append((method, path))
if method == "GET" and path.startswith("/iam/v1alpha1/applications/"):
return {"name": self.name}
if method == "GET" and path.startswith("/iam/v1alpha1/api-keys?"):
return {"api_keys": list(self.keys), "total_count": len(self.keys)}
if method == "POST":
assert data["application_id"] == gate.APPLICATION_ID
assert data["default_project_id"] == gate.PROJECT_ID
self.keys.append({"application_id": gate.APPLICATION_ID,
"default_project_id": gate.PROJECT_ID,
"description": data["description"]})
return dict(self.issued)
if method == "DELETE":
return {}
raise AssertionError((method, path))
def test_mint_proves_then_writes_the_minted_key_not_the_supplier():
api = _FakeConsole()
seen = {}
def prove(pair, project):
seen["prove"] = (pair["AWS_ACCESS_KEY_ID"], project)
def write(pair):
seen["write"] = dict(pair)
return {"current_version": 1}
receipt = gate.mint_workload_pair(api, prove, write, sleep=lambda _: None)
assert seen["prove"] == ("SCWMINTED", gate.PROJECT_ID)
assert seen["write"] == {"AWS_ACCESS_KEY_ID": "SCWMINTED", "AWS_SECRET_ACCESS_KEY": "minted-secret"}
assert receipt["status"] == "passed" and receipt["keys_after"]["count"] == 2
assert "minted-secret" not in json.dumps(receipt) and "SCWMINTED" not in json.dumps(receipt)
assert not any(method == "DELETE" for method, _ in api.calls)
def test_mint_retries_propagation_then_deletes_the_key_when_proof_never_passes():
api = _FakeConsole()
sleeps = []
def prove(pair, project):
raise gate.GateError("publisher_denials_failed")
with pytest.raises(gate.GateError) as caught:
gate.mint_workload_pair(api, prove, lambda pair: None, sleep=sleeps.append)
assert caught.value.code == "publisher_denials_failed"
assert caught.value.detail == {"minted_key_deleted": True}
assert len(sleeps) == gate.PROOF_ATTEMPTS - 1
assert ("DELETE", "/iam/v1alpha1/api-keys/SCWMINTED") in api.calls
def test_mint_deletes_the_key_when_the_pair_write_fails():
api = _FakeConsole()
def write(pair):
raise gate.GateError("existing_custody_requires_rotation_review")
with pytest.raises(gate.GateError) as caught:
gate.mint_workload_pair(api, lambda pair, project: None, write)
assert caught.value.code == "existing_custody_requires_rotation_review"
assert caught.value.detail["minted_key_deleted"] is True
@pytest.mark.parametrize("console", [
_FakeConsole(name="email-connect-backup-primary"),
_FakeConsole(keys=[]),
_FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID}] * 2),
_FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": "other"}]),
])
def test_mint_refuses_before_issuing_when_application_or_key_state_is_unexpected(console):
with pytest.raises(gate.GateError):
gate.mint_workload_pair(console, lambda *a: None, lambda pair: None)
assert not any(method in ("POST", "DELETE") for method, _ in console.calls)
def test_incomplete_issue_is_deleted_and_never_written():
api = _FakeConsole(issued={"application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED"})
written = []
with pytest.raises(gate.GateError) as caught:
gate.mint_workload_pair(api, lambda *a: None, written.append)
assert caught.value.code == "issued_key_incomplete"
assert caught.value.detail["minted_key_deleted"] is True and written == []
def test_main_refuses_without_the_mint_flag(tmp_path, monkeypatch):
import sys
monkeypatch.setattr(gate, "RECEIPT_DIR", tmp_path)
monkeypatch.setattr(sys, "stdout", sys.stdout)
monkeypatch.setattr(sys, "stderr", sys.stderr)
assert gate.main([]) == 71
assert json.loads((tmp_path / "mas-evidence-publisher-gate.json").read_text())["code"] == "usage_refused"

View file

@ -40,7 +40,7 @@ status: progress
priority: medium
needs_human: true
depends_on: [RPF-WP-0053-T05]
intervention_note: "Next: one attended publisher-gate login with the num_uses fix (ca7c46b). Done when the CAS=0 pair is written and the receipt records the MAS prefix HTTP 200 and the three AccessDenied denials. CCR-2026-0062 is applied. IAM application mas-evidence-backup-primary is 25a4cbca-220e-40c0-9af5-6c7713000174. The founder stored its API key at platform/suppliers/scaleway/mas-evidence-backup-primary, preferred project mail-address-semantics. The source bucket policy names that id under platform-pg/application-archives/mas/ with no object delete. The workload pair is still empty. Delivery admission stays false. Age-identity escrow remains separate."
intervention_note: "Next: one attended run of `node scripts/mas_evidence_iam_session.mjs --mint-workload --playwright-module <playwright index.mjs>`: sign in to the Scaleway Console, press Enter, then complete the OpenBao platform-admin login. Done when ~/.local/state/railiance/receipts/mas-evidence-workload-mint.json says passed: a second key on the MAS application, its prefix HTTP 200 and three AccessDenied denials, and the CAS=0 pair. CCR-2026-0062 is applied. IAM application mas-evidence-backup-primary is 25a4cbca-220e-40c0-9af5-6c7713000174. The founder stored its API key at platform/suppliers/scaleway/mas-evidence-backup-primary, preferred project mail-address-semantics. The source bucket policy names that id under platform-pg/application-archives/mas/ with no object delete. The workload pair is still empty. Delivery admission stays false. Age-identity escrow remains separate."
state_hub_task_id: "4f6484fb-8303-5dd5-94cb-b2622e1322c8"
```
@ -88,6 +88,22 @@ Design: `docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md`.
2026-11-02: further IAM tuning stays off the console form. `scripts/mas_evidence_iam_grant.py` uses one temporary Scaleway Console session, the same stdin shape as the Coulomb identity bootstrap. The first run created `mas-evidence-backup-primary` on project `mail-address-semantics`. The receipt showed the bucket publisher `coulomb-social-backup-primary` on project `reef-storage`, while `coulomb-recovery-project-publisher` is the separate recovery project. The second run replaced that one MAS rule and moved the single API key's preferred project to `reef-storage` (`e1a0dd0e-04b8-4ea9-8b30-d53f8c35c688`). Outcome `rule_replaced`, status `aligned`. The following publisher login proved the MAS prefix with HTTP 200 and the three denials with `AccessDenied`. The pair was not written: the OpenBao 2.5 CLI omits `num_uses` from `token create` JSON, so the gate rejected a writer whose policies and 300-second ttl were otherwise exact. The gate now reads `num_uses` from the raw create response.
2026-11-02: the gate no longer copies the founder-held supplier key onto
the workload path, which the lane design forbids. `--mint-workload` runs
inside the attended OpenBao platform-admin login, started by
`mas_evidence_iam_session.mjs --mint-workload`. Before it reads the
Console session, it checks that the workload path is empty and installs the
writer policy. It then mints one more API key on application
`25a4cbca-220e-40c0-9af5-6c7713000174` with preferred project
`reef-storage`, and refuses unless that application has exactly one
existing key. It proves the new key against the prefix and the three
denials, retrying up to 7 times for propagation, and writes it CAS=0 over
the OpenBao HTTP API. No value touches a file. If any step after the mint
fails, the gate deletes the minted key and the receipt records that it did.
The supplier key stays founder custody and can be revoked separately.
Tests cover the mint, retry, deletion and refusal paths. The attended
run is still pending.
## Admit delivery and place the node file
```task