Admit and install the backup pair writer.
The OpenBao policy matches the reviewed file. Both publisher KV paths are still empty because the bootstrap key cannot create IAM applications. Assistant: grok Assistant-Session: 01a11cdb-f9fe-7011-947c-936e72007e54
This commit is contained in:
parent
acbc78ffea
commit
9b658e3f19
8 changed files with 280 additions and 8 deletions
|
|
@ -32,6 +32,19 @@ call, key generation, file write or timer change.
|
|||
- 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.
|
||||
The founder admitted this identity on 2026-10-08. The words used were
|
||||
"bucket-pair-writer"; the OpenBao policy name is `backup-pair-writer`.
|
||||
No implemented SecurityCaseType covers the pair: `workload-kv-read` is still
|
||||
only an inventory candidate, and ops-mason can survey structure but cannot
|
||||
write it. The attended install is `scripts/install-backup-pair-writer.sh`.
|
||||
The installed policy matches the file
|
||||
(`86e435900bc6912fada10c526bfa865127a0e28acd328cf5f5f69e8d62bda310`), so
|
||||
`WRITER_ADMITTED` is true. The command-line preview still does not write.
|
||||
|
||||
Both KV paths are absent. The bootstrap key is denied IAM application and
|
||||
policy list (HTTP 403), so it cannot create the two publisher applications.
|
||||
The live bucket policy `resource-control-scoped-backup` allows one principal,
|
||||
`application_id:ee80333e-5bab-4a04-85a9-5bdb46b7b3c1`, under `platform-pg/*`,
|
||||
including object delete. No second application was added and no key was
|
||||
stored. CCR-2026-0036 and CCR-2026-0037 stay draft. The next founder act is
|
||||
creating the two IAM applications in the console, then one CAS=0 write each.
|
||||
|
|
|
|||
28
openbao/policies/backup-pair-writer.hcl
Normal file
28
openbao/policies/backup-pair-writer.hcl
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Attended writer for the two application-archive publisher pairs.
|
||||
# Founder admission 2026-10-08. CAS=0 and one-write behavior are enforced by
|
||||
# scripts/application_backup_pair_custody.py. This policy cannot delete,
|
||||
# list, or read secret values.
|
||||
|
||||
path "platform/data/workloads/coulomb-social/backup-primary" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "platform/data/workloads/email-connect/backup-primary" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/coulomb-social/backup-primary" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "platform/metadata/workloads/email-connect/backup-primary" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "auth/token/revoke-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ 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
|
||||
# Founder admission 2026-10-08. The installed OpenBao policy matches
|
||||
# openbao/policies/backup-pair-writer.hcl. The CLI still does not write.
|
||||
WRITER_ADMITTED = True
|
||||
PROFILES = {
|
||||
"coulomb-social": "CCR-2026-0036",
|
||||
"email-connect": "CCR-2026-0037",
|
||||
|
|
|
|||
11
scripts/install-backup-pair-writer.sh
Executable file
11
scripts/install-backup-pair-writer.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
# Install backup-pair-writer. Silent: warden access rejects any child output.
|
||||
set -eu
|
||||
policy=/home/worsch/railiance-platform/openbao/policies/backup-pair-writer.hcl
|
||||
receipt_dir=/home/worsch/.local/state/railiance/receipts
|
||||
umask 077
|
||||
mkdir -p "$receipt_dir"
|
||||
bao policy write backup-pair-writer "$policy" >/dev/null 2>&1 || exit 31
|
||||
bao policy read backup-pair-writer >"$receipt_dir/backup-pair-writer.policy" 2>/dev/null || exit 32
|
||||
sha256sum "$receipt_dir/backup-pair-writer.policy" >"$receipt_dir/backup-pair-writer.sha256" || exit 33
|
||||
exit 0
|
||||
153
scripts/survey-backup-iam.py
Normal file
153
scripts/survey-backup-iam.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Survey Scaleway IAM names and the backup bucket policy.
|
||||
|
||||
Reads the bootstrap and scoped-backup pairs in memory. Writes names, ids and
|
||||
the bucket policy only. Never prints a credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
RECEIPT = Path("/home/worsch/.local/state/railiance/receipts/backup-iam-survey.json")
|
||||
STAGE = Path("/home/worsch/.local/state/railiance/receipts/backup-iam-survey.stage")
|
||||
BUCKET = "railiance-platform-pg-backup"
|
||||
HOST = "s3.nl-ams.scw.cloud"
|
||||
|
||||
|
||||
def stage(text):
|
||||
STAGE.parent.mkdir(mode=0o700, exist_ok=True)
|
||||
STAGE.write_text(text + "\n")
|
||||
STAGE.chmod(0o600)
|
||||
|
||||
|
||||
def kv(path):
|
||||
proc = subprocess.run(
|
||||
["bao", "kv", "get", "-format=json", path],
|
||||
capture_output=True,
|
||||
)
|
||||
if proc.returncode:
|
||||
stage(f"kv_read_failed {path} {proc.returncode}")
|
||||
raise SystemExit(41)
|
||||
stage(f"kv_read_ok {path}")
|
||||
return json.loads(proc.stdout)["data"]["data"]
|
||||
|
||||
|
||||
def scaleway(secret, url):
|
||||
request = urllib.request.Request(url, headers={"X-Auth-Token": secret})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def sign(access_key, secret_key, canonical_uri, query):
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
day = now.strftime("%Y%m%d")
|
||||
payload = hashlib.sha256(b"").hexdigest()
|
||||
canonical_query = urllib.parse.urlencode(sorted(query.items()))
|
||||
canonical = "\n".join(
|
||||
[
|
||||
"GET",
|
||||
canonical_uri,
|
||||
canonical_query,
|
||||
f"host:{HOST}",
|
||||
f"x-amz-content-sha256:{payload}",
|
||||
f"x-amz-date:{amz_date}",
|
||||
"",
|
||||
"host;x-amz-content-sha256;x-amz-date",
|
||||
payload,
|
||||
]
|
||||
)
|
||||
scope = f"{day}/nl-ams/s3/aws4_request"
|
||||
to_sign = "\n".join(
|
||||
["AWS4-HMAC-SHA256", amz_date, scope, hashlib.sha256(canonical.encode()).hexdigest()]
|
||||
)
|
||||
key = ("AWS4" + secret_key).encode()
|
||||
for part in (day, "nl-ams", "s3", "aws4_request"):
|
||||
key = hmac.new(key, part.encode(), hashlib.sha256).digest()
|
||||
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"Authorization": (
|
||||
"AWS4-HMAC-SHA256 "
|
||||
f"Credential={access_key}/{scope}, "
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, "
|
||||
f"Signature={signature}"
|
||||
),
|
||||
"x-amz-date": amz_date,
|
||||
"x-amz-content-sha256": payload,
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(access_key, secret_key):
|
||||
query = {"policy": ""}
|
||||
headers = sign(access_key, secret_key, f"/{BUCKET}", query)
|
||||
url = f"https://{HOST}/{BUCKET}?policy"
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def main():
|
||||
boot = kv("platform/workloads/railiance/scaleway/bootstrap")
|
||||
backup = kv("platform/workloads/railiance/backup/object-storage")
|
||||
org = boot["DEFAULT_ORGANIZATION_ID"]
|
||||
project = boot["DEFAULT_PROJECT_ID"]
|
||||
app_status, applications = scaleway(
|
||||
boot["SECRET_KEY"],
|
||||
"https://api.scaleway.com/iam/v1alpha1/applications"
|
||||
f"?organization_id={urllib.parse.quote(org)}&page_size=100",
|
||||
)
|
||||
policy_status, policies = scaleway(
|
||||
boot["SECRET_KEY"],
|
||||
"https://api.scaleway.com/iam/v1alpha1/policies"
|
||||
f"?organization_id={urllib.parse.quote(org)}&page_size=100",
|
||||
)
|
||||
s3_status, document = bucket_policy(backup["ACCESS_KEY"], backup["SECRET_KEY"])
|
||||
receipt = {
|
||||
"organization_id": org,
|
||||
"project_id": project,
|
||||
"application_list_status": app_status,
|
||||
"applications": [
|
||||
{"id": item.get("id"), "name": item.get("name")}
|
||||
for item in (applications or {}).get("applications", [])
|
||||
],
|
||||
"policy_list_status": policy_status,
|
||||
"policies": [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"name": item.get("name"),
|
||||
"application_id": (item.get("application_id") or item.get("principal", {}).get("id")),
|
||||
}
|
||||
for item in (policies or {}).get("policies", [])
|
||||
],
|
||||
"bucket_policy_status": s3_status,
|
||||
"bucket_policy": document,
|
||||
}
|
||||
RECEIPT.parent.mkdir(mode=0o700, exist_ok=True)
|
||||
RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||
RECEIPT.chmod(0o600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
stage("ok")
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
stage("failed " + type(exc).__name__)
|
||||
raise SystemExit(42) from None
|
||||
36
scripts/survey-backup-pair-metadata.sh
Executable file
36
scripts/survey-backup-pair-metadata.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
#!/bin/sh
|
||||
# Record KV metadata versions only. Silent for warden access.
|
||||
set -eu
|
||||
receipt_dir=/home/worsch/.local/state/railiance/receipts
|
||||
umask 077
|
||||
mkdir -p "$receipt_dir"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
python3 - "$tmp" "$receipt_dir/backup-pair-metadata.json" << 'PY'
|
||||
import json, subprocess, sys
|
||||
tmp, dest = sys.argv[1:]
|
||||
paths = [
|
||||
"platform/workloads/coulomb-social/backup-primary",
|
||||
"platform/workloads/email-connect/backup-primary",
|
||||
"platform/workloads/railiance/scaleway/bootstrap",
|
||||
"platform/workloads/railiance/backup/object-storage",
|
||||
]
|
||||
rows = []
|
||||
for path in paths:
|
||||
out = subprocess.run(
|
||||
["bao", "kv", "metadata", "get", "-format=json", path],
|
||||
capture_output=True,
|
||||
)
|
||||
row = {"path": path, "returncode": out.returncode}
|
||||
if out.returncode == 0:
|
||||
data = json.loads(out.stdout)["data"]
|
||||
row["current_version"] = data.get("current_version")
|
||||
versions = data.get("versions") or {}
|
||||
row["version_count"] = len(versions)
|
||||
else:
|
||||
err = (out.stderr + out.stdout).decode("utf-8", "replace")
|
||||
row["absent"] = "No value found" in err
|
||||
rows.append(row)
|
||||
open(dest, "w").write(json.dumps({"paths": rows}, indent=2) + "\n")
|
||||
PY
|
||||
exit 0
|
||||
|
|
@ -53,7 +53,7 @@ def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, mo
|
|||
receipt = json.loads(capsys.readouterr().out)
|
||||
assert receipt["status"] == "preview"
|
||||
assert receipt["ccr_status"] == "draft"
|
||||
assert receipt["writer_admitted"] is False
|
||||
assert receipt["writer_admitted"] is True
|
||||
assert receipt["openbao_write"] is False
|
||||
assert receipt["provider_keys"] == "not_created"
|
||||
assert receipt["effects"] == []
|
||||
|
|
@ -63,7 +63,8 @@ def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, mo
|
|||
assert "AKIA" not in rendered
|
||||
|
||||
|
||||
def test_unreviewed_writer_makes_no_transport_call():
|
||||
def test_unreviewed_writer_makes_no_transport_call(monkeypatch):
|
||||
monkeypatch.setattr(custody, "WRITER_ADMITTED", False)
|
||||
transport = Transport()
|
||||
with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"):
|
||||
custody.provision("email-connect", PAIR, identity(), transport)
|
||||
|
|
@ -101,6 +102,28 @@ def test_reader_policy_and_existing_version_are_refused(monkeypatch):
|
|||
assert [call[0] for call in transport.calls] == ["metadata"]
|
||||
|
||||
|
||||
def test_writer_policy_is_pair_scoped_and_cannot_read_or_delete():
|
||||
text = (ROOT / "openbao/policies/backup-pair-writer.hcl").read_text(encoding="utf-8")
|
||||
for application in custody.PROFILES:
|
||||
assert f'path "platform/data/workloads/{application}/backup-primary"' in text
|
||||
assert f'path "platform/metadata/workloads/{application}/backup-primary"' in text
|
||||
capabilities = [
|
||||
line.split("=", 1)[1]
|
||||
for line in text.splitlines()
|
||||
if line.strip().startswith("capabilities")
|
||||
]
|
||||
assert capabilities
|
||||
assert all("delete" not in line and "list" not in line for line in capabilities)
|
||||
assert 'capabilities = ["read"]' in text
|
||||
data_blocks = [
|
||||
block
|
||||
for block in text.split("path ")
|
||||
if "platform/data/" in block
|
||||
]
|
||||
assert data_blocks
|
||||
assert all('["create", "update"]' in block and "read" not in block for block in data_blocks)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -227,8 +227,14 @@ remain open; no credentials delivered, productive capture/upload or timer enable
|
|||
## 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
|
||||
intended writer policy is `backup-pair-writer` and The founder admitted this writer on 2026-10-08, using the words
|
||||
"bucket-pair-writer". The policy name remains `backup-pair-writer`
|
||||
(`openbao/policies/backup-pair-writer.hcl`). The installed policy matches
|
||||
that file, and `WRITER_ADMITTED` is true. Both backup-primary KV paths are
|
||||
absent. The bootstrap key cannot list IAM applications (403). The live bucket
|
||||
policy still names only application ee80333e-5bab-4a04-85a9-5bdb46b7b3c1 on
|
||||
`platform-pg/*`. No publisher key was created. T05 stays wait for the two
|
||||
console-created applications and their CAS=0 pairs. 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue