Preview unadmitted backup pair custody.
The helper checks both draft archive CCRs and refuses a CAS write until the writer identity is admitted. No provider key is created. Assistant: grok Assistant-Session: 01a11cdb-f9fe-7011-947c-936e72007e54
This commit is contained in:
parent
d0525c836d
commit
715b0064ce
4 changed files with 358 additions and 0 deletions
|
|
@ -0,0 +1,37 @@
|
|||
# Backup publisher pair custody — preview, 2026-10-08
|
||||
|
||||
RPF-WP-0053-T06. The node reader in `scripts/application_backup_credentials.py`
|
||||
can deliver an already stored pair. It cannot create one. Warden's paste-once
|
||||
desk writes one field per PUT and would drop the other half of
|
||||
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`.
|
||||
|
||||
`scripts/application_backup_pair_custody.py` is the prepared front door for
|
||||
both draft records:
|
||||
|
||||
| Application | CCR | KV path |
|
||||
| --- | --- | --- |
|
||||
| coulomb-social | CCR-2026-0036 | platform/workloads/coulomb-social/backup-primary |
|
||||
| email-connect | CCR-2026-0037 | platform/workloads/email-connect/backup-primary |
|
||||
|
||||
Default command:
|
||||
|
||||
```bash
|
||||
python3 scripts/application_backup_pair_custody.py --application email-connect
|
||||
```
|
||||
|
||||
The preview reads the draft CCR and prints field names, CAS 0 and one intended
|
||||
write. `writer_admitted` is false. There is no stdin, provider call, OpenBao
|
||||
call, key generation, file write or timer change.
|
||||
|
||||
`provision()` is the future attended shape, covered by synthetic tests only:
|
||||
|
||||
- writer token policies must be exactly `backup-pair-writer`, with no default
|
||||
policy and no `workload-kv-read-*-backup-primary` reader policy
|
||||
- TTL at most 5 minutes and at most 2 uses, then revoke
|
||||
- metadata absence is required; an existing version is not overwritten
|
||||
- one HTTP-style body carries `cas: 0` and both fields
|
||||
- the receipt keeps version and request id, never the values
|
||||
|
||||
`WRITER_ADMITTED` stays false, so that function refuses before any transport
|
||||
call. No Scaleway IAM application or key was created. CCR-2026-0036 and
|
||||
CCR-2026-0037 stay draft. EMAIL-WP-0007-T03/T04 stay waiting on this admission.
|
||||
143
scripts/application_backup_pair_custody.py
Normal file
143
scripts/application_backup_pair_custody.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Preview custody for the two backup publisher pairs.
|
||||
|
||||
A later attended act may store both AWS fields in one CAS=0 write. This
|
||||
command does not generate provider keys, contact OpenBao, or perform that
|
||||
write. The delivery reader in application_backup_credentials.py cannot
|
||||
create or rotate these values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIELDS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY")
|
||||
WRITER_POLICY = "backup-pair-writer"
|
||||
WRITER_ADMITTED = False
|
||||
PROFILES = {
|
||||
"coulomb-social": "CCR-2026-0036",
|
||||
"email-connect": "CCR-2026-0037",
|
||||
}
|
||||
VALUE = re.compile(r"[!-~]{16,128}\Z")
|
||||
|
||||
|
||||
class CustodyError(Exception):
|
||||
"""Fixed diagnostic. It never carries key material."""
|
||||
|
||||
|
||||
def guard(condition, code):
|
||||
if not condition:
|
||||
raise CustodyError(code)
|
||||
|
||||
|
||||
def kv_path(application):
|
||||
guard(application in PROFILES, "unknown_application")
|
||||
return f"platform/workloads/{application}/backup-primary"
|
||||
|
||||
|
||||
def ccr_path(application):
|
||||
prefix = PROFILES[application]
|
||||
return ROOT / "credential-change-requests" / f"{prefix}-{application}-backup-primary.yaml"
|
||||
|
||||
|
||||
def preview(application):
|
||||
raw = ccr_path(application).read_text(encoding="utf-8")
|
||||
document = json.loads(raw)
|
||||
guard(document.get("id") == PROFILES[application], "ccr_mismatch")
|
||||
guard(document.get("openbao", {}).get("fields") == list(FIELDS), "field_mismatch")
|
||||
guard(document.get("openbao", {}).get("kv_path") == kv_path(application), "path_mismatch")
|
||||
return {
|
||||
"status": "preview",
|
||||
"application": application,
|
||||
"ccr": document["id"],
|
||||
"ccr_status": document.get("status"),
|
||||
"kv_path": kv_path(application),
|
||||
"fields": list(FIELDS),
|
||||
"cas": 0,
|
||||
"writes": 1,
|
||||
"writer_policy": WRITER_POLICY,
|
||||
"writer_admitted": WRITER_ADMITTED,
|
||||
"provider_keys": "not_created",
|
||||
"openbao_write": False,
|
||||
"effects": [],
|
||||
}
|
||||
|
||||
|
||||
def validate_pair(values):
|
||||
guard(isinstance(values, dict) and set(values) == set(FIELDS), "exact_pair_required")
|
||||
stored = []
|
||||
for name in FIELDS:
|
||||
value = values[name]
|
||||
guard(isinstance(value, str) and VALUE.fullmatch(value), "invalid_pair_value")
|
||||
stored.append(value)
|
||||
guard(stored[0] != stored[1], "pair_values_must_differ")
|
||||
return {name: values[name] for name in FIELDS}
|
||||
|
||||
|
||||
def assert_writer(identity, application):
|
||||
"""Refuse the delivery reader, a generic grant, and any unreviewed writer."""
|
||||
guard(WRITER_ADMITTED, "writer_identity_unreviewed")
|
||||
policies = identity.get("policies")
|
||||
reader = f"workload-kv-read-{application}-backup-primary"
|
||||
guard(
|
||||
policies == [WRITER_POLICY]
|
||||
and identity.get("identity_policies") == []
|
||||
and reader not in policies
|
||||
and "default" not in policies,
|
||||
"writer_identity_unreviewed",
|
||||
)
|
||||
ttl = identity.get("ttl")
|
||||
uses = identity.get("num_uses")
|
||||
guard(type(ttl) is int and 0 < ttl <= 300, "writer_ttl_unbounded")
|
||||
guard(type(uses) is int and 0 < uses <= 2, "writer_uses_unbounded")
|
||||
|
||||
|
||||
def provision(application, values, identity, transport):
|
||||
"""One metadata check and one CAS=0 write. Tests inject transport.
|
||||
|
||||
Live use stays disconnected while WRITER_ADMITTED is false.
|
||||
"""
|
||||
path = kv_path(application)
|
||||
pair = validate_pair(values)
|
||||
assert_writer(identity, application)
|
||||
metadata = transport.metadata(f"platform/metadata/workloads/{application}/backup-primary")
|
||||
guard(metadata is None, "existing_custody_requires_rotation_review")
|
||||
written = transport.put_cas0(
|
||||
f"platform/data/workloads/{application}/backup-primary",
|
||||
{"options": {"cas": 0}, "data": pair},
|
||||
)
|
||||
try:
|
||||
guard(written.get("version") == 1 and set(written) <= {"version", "request_id"}, "unexpected_version")
|
||||
return {
|
||||
"application": application,
|
||||
"kv_path": path,
|
||||
"cas": 0,
|
||||
"kv_version": 1,
|
||||
"field_names": list(FIELDS),
|
||||
"request_id": written.get("request_id"),
|
||||
"secret_material": False,
|
||||
}
|
||||
finally:
|
||||
transport.revoke()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--application", required=True, choices=sorted(PROFILES))
|
||||
args = parser.parse_args(argv)
|
||||
json.dump(preview(args.application), sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CustodyError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
115
tests/test_application_backup_pair_custody.py
Normal file
115
tests/test_application_backup_pair_custody.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Pair custody stays preview-only until a writer identity is admitted."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"pair_custody", ROOT / "scripts/application_backup_pair_custody.py"
|
||||
)
|
||||
custody = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custody)
|
||||
|
||||
PAIR = {
|
||||
"AWS_ACCESS_KEY_ID": "SYNTHETICACCESSKEY123",
|
||||
"AWS_SECRET_ACCESS_KEY": "SYNTHETICSECRETKEY4567",
|
||||
}
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.metadata_result = None
|
||||
self.version = 1
|
||||
|
||||
def metadata(self, path):
|
||||
self.calls.append(("metadata", path))
|
||||
return self.metadata_result
|
||||
|
||||
def put_cas0(self, path, body):
|
||||
self.calls.append(("put", path, body["options"]["cas"], tuple(body["data"])))
|
||||
return {"version": self.version, "request_id": "synthetic-request"}
|
||||
|
||||
def revoke(self):
|
||||
self.calls.append(("revoke",))
|
||||
|
||||
|
||||
def identity():
|
||||
return {
|
||||
"policies": [custody.WRITER_POLICY],
|
||||
"identity_policies": [],
|
||||
"ttl": 300,
|
||||
"num_uses": 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("application", sorted(custody.PROFILES))
|
||||
def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, monkeypatch):
|
||||
monkeypatch.setattr(custody, "provision", lambda *_: pytest.fail("preview provisioned"))
|
||||
assert custody.main(["--application", application]) == 0
|
||||
receipt = json.loads(capsys.readouterr().out)
|
||||
assert receipt["status"] == "preview"
|
||||
assert receipt["ccr_status"] == "draft"
|
||||
assert receipt["writer_admitted"] is False
|
||||
assert receipt["openbao_write"] is False
|
||||
assert receipt["provider_keys"] == "not_created"
|
||||
assert receipt["effects"] == []
|
||||
assert receipt["fields"] == list(custody.FIELDS)
|
||||
rendered = json.dumps(receipt)
|
||||
assert "SYNTHETIC" not in rendered
|
||||
assert "AKIA" not in rendered
|
||||
|
||||
|
||||
def test_unreviewed_writer_makes_no_transport_call():
|
||||
transport = Transport()
|
||||
with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"):
|
||||
custody.provision("email-connect", PAIR, identity(), transport)
|
||||
assert transport.calls == []
|
||||
|
||||
|
||||
def test_admitted_writer_uses_one_cas0_write_and_revokes(monkeypatch):
|
||||
monkeypatch.setattr(custody, "WRITER_ADMITTED", True)
|
||||
transport = Transport()
|
||||
receipt = custody.provision("email-connect", dict(PAIR), identity(), transport)
|
||||
assert receipt["kv_version"] == 1
|
||||
assert receipt["secret_material"] is False
|
||||
assert PAIR["AWS_SECRET_ACCESS_KEY"] not in json.dumps(receipt)
|
||||
assert transport.calls == [
|
||||
("metadata", "platform/metadata/workloads/email-connect/backup-primary"),
|
||||
(
|
||||
"put",
|
||||
"platform/data/workloads/email-connect/backup-primary",
|
||||
0,
|
||||
custody.FIELDS,
|
||||
),
|
||||
("revoke",),
|
||||
]
|
||||
|
||||
|
||||
def test_reader_policy_and_existing_version_are_refused(monkeypatch):
|
||||
monkeypatch.setattr(custody, "WRITER_ADMITTED", True)
|
||||
reader = dict(identity(), policies=["workload-kv-read-email-connect-backup-primary"])
|
||||
with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"):
|
||||
custody.provision("email-connect", PAIR, reader, Transport())
|
||||
transport = Transport()
|
||||
transport.metadata_result = {"current_version": 1}
|
||||
with pytest.raises(custody.CustodyError, match="existing_custody_requires_rotation_review"):
|
||||
custody.provision("email-connect", PAIR, identity(), transport)
|
||||
assert [call[0] for call in transport.calls] == ["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
{"AWS_ACCESS_KEY_ID": PAIR["AWS_ACCESS_KEY_ID"]},
|
||||
dict(PAIR, AWS_SECRET_ACCESS_KEY="short"),
|
||||
dict(PAIR, AWS_ACCESS_KEY_ID=PAIR["AWS_SECRET_ACCESS_KEY"]),
|
||||
dict(PAIR, EXTRA="SYNTHETICEXTRAVALUE1"),
|
||||
],
|
||||
)
|
||||
def test_partial_or_embedded_pairs_are_refused(values):
|
||||
with pytest.raises(custody.CustodyError):
|
||||
custody.validate_pair(values)
|
||||
|
|
@ -173,3 +173,66 @@ never auto-enable a recovered receiver or resend an unknown outcome. Exact
|
|||
first pause/upload/schedule, actual offsite/recovery proof and failure reception
|
||||
remain EMAIL-WP-0007-T04/CSOC-WP-0010-T04/T05 obligations. Current receiver image
|
||||
and SMTP/sender/caller admission remain unchanged by source preparation.
|
||||
|
||||
|
||||
## T06 — Prepare atomic pair custody and its admitted front door
|
||||
|
||||
```task
|
||||
id: RPF-WP-0053-T06
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
2026-10-08 delivery preparation exposed a concrete custody gap: Warden's generic
|
||||
paste-once desk performs a single-field KV PUT, replacing the whole record. Two
|
||||
runs at one path discard the first field. Prepare a platform-owned pair-aware
|
||||
custody procedure and reviewed writer identity/front door for the two new records,
|
||||
initial CAS=0, both AWS fields in one atomic write, no secret argv/output/files,
|
||||
bounded auth/self-revocation and metadata-only KV-version evidence. Do not use
|
||||
the read-only delivery role, the structure-only administration lane, a generic
|
||||
agent read grant, a retired ops-mason builder grant or two single-field desk PUTs.
|
||||
Design/implementation can progress independently; actual provider key provisioning
|
||||
needs its exact owner-reviewed attended custody act. This is a live handoff from
|
||||
WARDEN-WP-0043 and the credential-delivery preparation, not a hidden prose residual.
|
||||
|
||||
## 2026-10-08 — Credential delivery preparation
|
||||
|
||||
Draft CCR-2026-0036 and CCR-2026-0037 now bind separate application KV paths,
|
||||
five-minute/four-use native readers, exact namespaces/ServiceAccount/audience and
|
||||
root-owned private delivery files. Both remain draft/unconfirmed, with no reviews
|
||||
or native admission evidence. Their read policies match the existing generator;
|
||||
validator errors and delegated-applier scope violations are empty, with expected
|
||||
unconfirmed-binding warnings. Proposed ServiceAccounts disable automount and have
|
||||
no new RBAC grants. No existing credential, Secret or cluster object was read or
|
||||
changed.
|
||||
|
||||
scripts/application_backup_credentials.py previews without effects and performs
|
||||
only admitted, source-hash/KV-version-pinned node reads; token revoked before atomic
|
||||
no-overwrite private publication, readback equality, metadata-only receipts. It
|
||||
refuses source/scope/version drift, dangerous files, injection, unconfirmed revoke
|
||||
and a different existing file. A separate YAML-to-public-JSON exporter preserves
|
||||
canonical source updates while keeping the node helper dependency-free. Thirty
|
||||
synthetic delivery cases, four export cases and thirty primary/retention cases
|
||||
pass; none establish live authorization. Review:
|
||||
docs/credential-lane-designs/application-backup-delivery-2026-10-08.md.
|
||||
|
||||
WARDEN-WP-0043 fixes administration/provisioning intent collisions with surveys;
|
||||
installed Warden was rebuilt, phase-memory retained, current catalog hash
|
||||
5e6b217aa1f6 verified. Surveys now point to ops-mason-survey per MASON-IN-0004.
|
||||
Structure requests require attended OIDC; pair provisioning is only a generic
|
||||
platform pointer until T06 supplies the correct custody contract. T01/T02/T03/T05
|
||||
remain open; no credentials delivered, productive capture/upload or timer enabled.
|
||||
|
||||
## 2026-10-08 — Pair writer preview
|
||||
|
||||
`scripts/application_backup_pair_custody.py` previews both draft CCRs. The
|
||||
intended writer policy is `backup-pair-writer` and `WRITER_ADMITTED` is false,
|
||||
so `provision()` returns before metadata or CAS. An admitted writer, covered
|
||||
only by a synthetic transport, must present that one policy, TTL at most 5
|
||||
minutes and at most 2 uses, prove metadata absence, send both AWS fields in one
|
||||
`cas: 0` body, and revoke. Receipts contain no values. The delivery reader and
|
||||
a one-field desk remain refused. No IAM application, provider key, OpenBao
|
||||
version or node file was created. Review:
|
||||
docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md.
|
||||
T05 stays wait for owner admission of the writer, prefix and escrow. T06 stays
|
||||
progress until that attended act.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue