Add an offline attribute-admission branch beside exact-record review.
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

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
This commit is contained in:
tegwick 2026-10-03 22:03:46 +02:00
parent d014453a1a
commit e85f511c4f
8 changed files with 1237 additions and 10 deletions

View file

@ -92,8 +92,12 @@ with tempfile.TemporaryDirectory() as temp:
# every act stays scoped to the act records, with the 900 s window.
list_only = json.loads((r / 'list_only_records.json').read_text())
listable = {k: dict(v, memo_version=1) for k, v in records.items()} | list_only
now = int(time.time())
for memo, record in listable.items():
# Sample the clock per check. A single `now` goes stale while each
# check reloads the package, and the 12h case has only 60s of slack.
def stamp():
return int(time.time())
request = {
'id': 'local-regression',
'tenant': 'tenant:platform',
@ -104,7 +108,7 @@ with tempfile.TemporaryDirectory() as temp:
'tenant_source': 'registration-supplied',
'principal_type_source': 'authentication-derived',
'assurance': {'level': 'aal2', 'mfa': True, 'methods': ['pwd', 'otp'],
'source': 'key-cape', 'at': now},
'source': 'key-cape', 'at': stamp()},
},
},
'resource': {'id': memo, 'type': 'decision-memo',
@ -125,14 +129,14 @@ with tempfile.TemporaryDirectory() as temp:
return c
at = ['subject', 'attributes', 'assurance', 'at']
check(label + ':fresh', request, 'allow')
check(label + ':fresh', variant(at, stamp()), 'allow')
# The observed production refusal: 1744 s, allowed for list...
check(label + ':observed-1744s', variant(at, now - 1744), 'allow')
check(label + ':inside-12h', variant(at, now - 43200 + 60), 'allow')
check(label + ':observed-1744s', variant(at, stamp() - 1744), 'allow')
check(label + ':inside-12h', variant(at, stamp() - 43200 + 60), 'allow')
# ...but the 12 h bound is real, and read does not follow list.
check(label + ':past-12h', variant(at, now - 43200 - 60), 'deny')
check(label + ':past-12h', variant(at, stamp() - 43200 - 60), 'deny')
is_act_record = memo in records
read_stale = variant(at, now - 1744) | {'action': 'read'}
read_stale = variant(at, stamp() - 1744) | {'action': 'read'}
check(label + ':read-stale-refused', read_stale, 'deny')
if not is_act_record:
# T03 records are listable only; no act widens to them, even fresh.
@ -143,7 +147,7 @@ with tempfile.TemporaryDirectory() as temp:
('no-group', ['subject', 'attributes', 'groups'], []),
('service', ['subject', 'type'], 'service'),
('agent', ['subject', 'type'], 'agent'),
('future-mfa', at, now + 300),
('future-mfa', at, stamp() + 300),
('no-mfa', ['subject', 'attributes', 'assurance', 'mfa'], False),
('aal1', ['subject', 'attributes', 'assurance', 'level'], 'aal1'),
('forged-human-route', ['subject', 'attributes', 'principal_type_source'], 'registration-supplied'),

View file

@ -0,0 +1,119 @@
#!/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())