access-engine/tools/gen_informed_decision_admission.py
tegwick e85f511c4f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 59s
Add an offline attribute-admission branch beside exact-record review.
Compact-sitting v8 can admit a new memo from carried class, submitter, and approval attributes, but shipped sets are empty and exact-record decisions stay on their pins. The sitting image pin is unchanged.

Assistant: grok
Assistant-Session: 01a10329-31cb-78d3-b7a3-788c2f91d6d4
2026-10-03 22:03:46 +02:00

119 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Generate attribute-admission sets from informed-decision's submitter registry.
The compact-sitting attribute branch does not author who may pose. It copies
submitter ids and the union of their memo classes from
``deploy/intake-submitters.json``. An empty registry generates empty sets, so
the branch admits nobody. A malformed registry refuses to write.
Usage:
python3 tools/gen_informed_decision_admission.py [--source PATH]
python3 tools/gen_informed_decision_admission.py [--source PATH] --check
``--check`` reports drift and writes nothing. ``--policy`` is for tests.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
POLICY = ROOT / "examples" / "informed-decision-sitting" / "policy.md"
DEFAULT_SOURCE = Path(os.path.expanduser("~/informed-decision/deploy/intake-submitters.json"))
BEGIN = "# BEGIN GENERATED admitted_sets (tools/gen_informed_decision_admission.py; do not edit by hand)"
END = "# END GENERATED admitted_sets"
SA = re.compile(r"^system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+$")
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}$")
ROW_KEYS = {"submitter_id", "memo_classes", "review_groups", "daily_limit"}
def fail(message: str) -> None:
raise SystemExit(message)
def load_sets(path: Path) -> tuple[list[str], list[str]]:
try:
document = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
fail(f"cannot read submitter registry {path}: {exc}")
if not isinstance(document, dict):
fail("intake submitters must be an object; refusing to generate")
submitters: list[str] = []
classes: set[str] = set()
for username, row in document.items():
if not isinstance(username, str) or not SA.fullmatch(username) or not isinstance(row, dict):
fail(f"refusing submitter row {username!r}")
if set(row) != ROW_KEYS:
fail(f"refusing submitter row {username!r}: keys must be {sorted(ROW_KEYS)}")
submitter_id = row["submitter_id"]
if not isinstance(submitter_id, str) or not SLUG.fullmatch(submitter_id):
fail(f"refusing submitter id {submitter_id!r}")
limit = row["daily_limit"]
if type(limit) is not int or limit < 1:
fail(f"refusing daily_limit for {username}")
for field in ("memo_classes", "review_groups"):
values = row[field]
if not isinstance(values, list) or any(not isinstance(item, str) or not SLUG.fullmatch(item) for item in values):
fail(f"refusing {field} for {username}")
submitters.append(submitter_id)
classes.update(row["memo_classes"])
if len(set(submitters)) != len(submitters):
fail("duplicate submitter_id; refusing to generate")
return sorted(set(submitters)), sorted(classes)
def render_set(name: str, values: list[str]) -> str:
if not values:
return f"{name} := set()"
body = "\n".join(f' "{value}",' for value in values)
return f"{name} := {{\n{body}\n}}"
def render_block(submitters: list[str], classes: list[str]) -> str:
return (
f"{BEGIN}\n"
f"{render_set('admitted_submitters', submitters)}\n"
"\n"
f"{render_set('admitted_classes', classes)}\n"
f"{END}"
)
def replace_block(policy: str, block: str, path: Path) -> str:
pattern = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END), re.S)
if not pattern.search(policy):
fail(f"{path}: generated markers not found")
return pattern.sub(lambda _: block, policy, count=1)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--policy", type=Path, default=POLICY)
parser.add_argument("--check", action="store_true", help="report drift, write nothing")
args = parser.parse_args()
submitters, classes = load_sets(args.source)
current = args.policy.read_text()
updated = replace_block(current, render_block(submitters, classes), args.policy)
if args.check:
if updated != current:
print(f"drift: {args.policy}", file=sys.stderr)
print(f"{len(submitters)} submitters, {len(classes)} classes; drift")
return 1
print(f"{len(submitters)} submitters, {len(classes)} classes; in sync")
return 0
if updated != current:
args.policy.write_text(updated)
print(f"wrote {args.policy}")
print(f"{len(submitters)} submitters, {len(classes)} classes")
return 0
if __name__ == "__main__":
sys.exit(main())