diff --git a/examples/informed-decision-sitting/attribute_admission_v8.json b/examples/informed-decision-sitting/attribute_admission_v8.json new file mode 100644 index 0000000..7a6dad8 --- /dev/null +++ b/examples/informed-decision-sitting/attribute_admission_v8.json @@ -0,0 +1,303 @@ +{ + "policy_id": "informed-decision.compact-sitting", + "policy_version": "v8", + "note": "Offline request/response fixtures. Shipped admitted sets are empty, so attribute_eligible is not in this file. An allow here is an exact-record pin, not proof of a live approval.", + "cases": [ + { + "id": "exact-record-allow", + "kind": "exact", + "assurance": "now", + "expect": {"effect": "allow", "reason": "operator_admitted_compact_sitting"}, + "request": { + "id": "exact-record-allow", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "accept", + "resource": {"id": "memo:infd-20260927-b02", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "memo_version": 1, + "approval_id": "1e73e514-ef74-4bff-83e7-fa88a3eaba0c", + "approval_binding_digest": "sha256:3e5951847e1909abe1561f561b4591228848a2e1a939b9bf5acab61e2db42773" + }, + "policy_version": "v8" + } + }, + { + "id": "exact-record-wrong-digest", + "kind": "exact", + "assurance": "now", + "expect": {"effect": "deny", "reason": "compact_sitting_scope_or_identity_refused"}, + "request": { + "id": "exact-record-wrong-digest", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "accept", + "resource": {"id": "memo:infd-20260927-b02", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "memo_version": 1, + "approval_id": "1e73e514-ef74-4bff-83e7-fa88a3eaba0c", + "approval_binding_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy_version": "v8" + } + }, + { + "id": "exact-record-with-attribute-context", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "allow", "reason": "operator_admitted_compact_sitting"}, + "request": { + "id": "exact-record-with-attribute-context", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "read", + "resource": {"id": "memo:infd-20260927-b02", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "1e73e514-ef74-4bff-83e7-fa88a3eaba0c", + "approval_binding_digest": "sha256:3e5951847e1909abe1561f561b4591228848a2e1a939b9bf5acab61e2db42773" + }, + "policy_version": "v8" + } + }, + { + "id": "exact-pin-mismatch-not-rescued", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "deny", "reason": "compact_sitting_scope_or_identity_refused"}, + "request": { + "id": "exact-pin-mismatch-not-rescued", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "accept", + "resource": {"id": "memo:infd-20260927-b02", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "1e73e514-ef74-4bff-83e7-fa88a3eaba0c", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "policy_version": "v8" + } + }, + { + "id": "attribute-unknown-class", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "deny", "reason": "unknown_class"}, + "request": { + "id": "attribute-unknown-class", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "decline", + "resource": {"id": "memo:intake-new-memo", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + } + }, + { + "id": "attribute-stale-mfa", + "kind": "attribute", + "assurance": "stale", + "expect": {"effect": "deny", "reason": "stale_mfa"}, + "request": { + "id": "attribute-stale-mfa", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "accept", + "resource": {"id": "memo:intake-new-memo", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": false, + "memo_version": 1, + "approval_id": "not-a-uuid", + "approval_binding_digest": "sha256:wrong" + }, + "policy_version": "v8" + } + }, + { + "id": "attribute-review-group", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "deny", "reason": "review_group_not_verified"}, + "request": { + "id": "attribute-review-group", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["other-group"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "discuss", + "resource": {"id": "memo:intake-new-memo", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 2, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + } + }, + { + "id": "attribute-bad-memo-id", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "deny", "reason": "resource_refused"}, + "request": { + "id": "attribute-bad-memo-id", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "return", + "resource": {"id": "memo:NO", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + } + }, + { + "id": "attribute-list-stays-exact", + "kind": "attribute", + "assurance": "now", + "expect": {"effect": "deny", "reason": "compact_sitting_scope_or_identity_refused"}, + "request": { + "id": "attribute-list-stays-exact", + "tenant": "tenant:platform", + "subject": { + "id": "synthetic-reviewer", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", "methods": ["pwd", "otp"], "at": 0} + } + }, + "action": "list", + "resource": {"id": "memo:intake-new-memo", "type": "decision-memo", "system": "informed-decision", "tenant": "tenant:platform"}, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + } + } + ] +} diff --git a/examples/informed-decision-sitting/attribute_fixtures.json b/examples/informed-decision-sitting/attribute_fixtures.json new file mode 100644 index 0000000..71a8d9d --- /dev/null +++ b/examples/informed-decision-sitting/attribute_fixtures.json @@ -0,0 +1,140 @@ +[ + { + "id": "attribute-non-human-refused", + "request": { + "id": "attribute-non-human", + "tenant": "tenant:platform", + "subject": { + "id": "not-a-person", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": { + "level": "aal2", + "mfa": true, + "source": "key-cape", + "methods": ["pwd", "otp"], + "at": 1 + } + } + }, + "action": "accept", + "resource": { + "id": "memo:intake-new-memo", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + }, + "expect": { + "effect": "deny", + "reason": "identity_refused" + } + }, + { + "id": "attribute-stale-mfa", + "request": { + "id": "attribute-stale-mfa", + "tenant": "tenant:platform", + "subject": { + "id": "test-human", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": { + "level": "aal2", + "mfa": true, + "source": "key-cape", + "methods": ["pwd", "otp"], + "at": 1 + } + } + }, + "action": "accept", + "resource": { + "id": "memo:intake-new-memo", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + }, + "expect": { + "effect": "deny", + "reason": "stale_mfa" + } + }, + { + "id": "attribute-future-mfa", + "request": { + "id": "attribute-future-mfa", + "tenant": "tenant:platform", + "subject": { + "id": "test-human", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": ["net-kingdom-admins"], + "assurance": { + "level": "aal2", + "mfa": true, + "source": "key-cape", + "methods": ["pwd", "otp"], + "at": 9999999999 + } + } + }, + "action": "read", + "resource": { + "id": "memo:intake-new-memo", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, + "policy_version": "v8" + }, + "expect": { + "effect": "deny", + "reason": "identity_refused" + } + } +] diff --git a/examples/informed-decision-sitting/policy.md b/examples/informed-decision-sitting/policy.md index 47db1ab..59d8166 100644 --- a/examples/informed-decision-sitting/policy.md +++ b/examples/informed-decision-sitting/policy.md @@ -2,13 +2,13 @@ id: informed-decision.compact-sitting name: Compact sitting exact-record human review namespace: informed-decision:decision-memo -version: v7 +version: v8 status: ready package: flexauth.informed_decision.compact_sitting allow_ttl: 30s actions: [read, acknowledge, accept, return, discuss, decline, list] owner: flex-auth -fixtures: [fixtures.json] +fixtures: [fixtures.json, attribute_fixtures.json] caring: profile: caring-0.4.0-rc2 enforce: false @@ -64,6 +64,28 @@ v7 (2026-10-03, INFD-WP-ADHOC-2026-10-03-T02) adds the exact from informed-decision `docs/evidence/2026-10-03-provisioning-window-approval-create.json`. Existing records and identity requirements are preserved. +v8 (2026-10-03, offline attribute admission) adds one branch beside those +exact-record rules. It is not the deployed sitting pin. Requests whose +resource id is in `records` or `listable` still take only the exact-record +rules, including when they also carry attribute fields, so an exact-record +decision does not change. `list` stays on the exact-record rule. + +The attribute branch may allow `read`, `acknowledge`, `accept`, `return`, +`discuss` and `decline` for any other memo id. `attribute_eligible` is +eligibility only. It checks that the carried approval id is a UUID and the +carried digest matches `sha256:` plus 64 lowercase hex digits. It does not +read an approval and it does not prove the memo pin. The effect owner still +reads the approval live and compares that id and the engine's native digest +with the memo. `approval_human_control` must be boolean true. No presentation +hash is read. Context fields are `review_group`, `memo_class`, +`intake_submitter`, `approval_requester`, `approval_human_control`, +`memo_version`, `approval_id` and `approval_binding_digest`. + +`admitted_submitters` and `admitted_classes` are generated from +informed-decision `deploy/intake-submitters.json`. The shipped registry is +empty, so both sets are empty and this branch admits nobody. Regenerate with +`python3 tools/gen_informed_decision_admission.py`; `--check` reports drift. + The identity bar matches T03: authenticated informed-decision caller, `net-kingdom-admins`, fresh KeyCape AAL2 MFA. No permission follows from memo content or presentation state. TokenReview must admit @@ -172,6 +194,103 @@ listable := object.union( list_only_records, ) +# BEGIN GENERATED admitted_sets (tools/gen_informed_decision_admission.py; do not edit by hand) +admitted_submitters := set() + +admitted_classes := set() +# END GENERATED admitted_sets + +exact_id if { + id := object.get(object.get(input, "resource", {}), "id", "") + listable[id] +} + +attribute_attempt if { + not exact_id + input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"} + ctx := object.get(input, "context", {}) + some key in {"review_group", "memo_class", "intake_submitter", "approval_requester", "approval_human_control"} + object.get(ctx, key, null) != null +} + +identity_core if { + input.tenant == "tenant:platform" + input.subject.tenant == "tenant:platform" + input.subject.type == "human" + is_string(input.subject.id) + input.subject.id != "" + input.subject.attributes.principal_type_source == "authentication-derived" + input.subject.attributes.tenant_source in {"registration-supplied", "directory-asserted"} + assurance := input.subject.attributes.assurance + assurance.level == "aal2" + assurance.mfa == true + assurance.source == "key-cape" + assurance.methods == ["pwd", "otp"] + is_number(assurance.at) + assurance.at > 0 +} + +mfa_fresh if { + identity_core + age := time.now_ns() / 1000000000 - input.subject.attributes.assurance.at + age >= -30 + age <= 900 +} + +stale_mfa if { + identity_core + age := time.now_ns() / 1000000000 - input.subject.attributes.assurance.at + age > 900 +} + +resource_ok if { + input.resource.tenant == "tenant:platform" + input.resource.system == "informed-decision" + input.resource.type == "decision-memo" + is_string(input.resource.id) + regex.match(`^memo:[a-z0-9][a-z0-9-]{2,62}$`, input.resource.id) +} + +review_group_ok if { + group := input.context.review_group + is_string(group) + group != "" + group in input.subject.attributes.groups +} + +class_ok if { + is_string(input.context.memo_class) + input.context.memo_class in admitted_classes +} + +submitter_ok if { + is_string(input.context.intake_submitter) + input.context.intake_submitter in admitted_submitters +} + +requester_ok if { + input.context.approval_requester == "informed-decision" +} + +human_control_ok if { + input.context.approval_human_control == true +} + +approval_id_ok if { + is_string(input.context.approval_id) + regex.match(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, input.context.approval_id) +} + +digest_ok if { + is_string(input.context.approval_binding_digest) + regex.match(`^sha256:[0-9a-f]{64}$`, input.context.approval_binding_digest) +} + +version_ok if { + is_number(input.context.memo_version) + input.context.memo_version >= 1 +} + decision := {"effect": "allow", "reason": "operator_admitted_compact_sitting"} if { input.tenant == "tenant:platform" input.subject.tenant == "tenant:platform" @@ -226,6 +345,54 @@ decision := {"effect": "allow", "reason": "operator_admitted_compact_sitting"} i input.context.memo_version == record.memo_version input.context.approval_id == record.approval_id input.context.approval_binding_digest == record.binding_digest +} else := {"effect": "allow", "reason": "attribute_eligible"} if { + attribute_attempt + mfa_fresh + resource_ok + review_group_ok + class_ok + submitter_ok + requester_ok + human_control_ok + approval_id_ok + digest_ok + version_ok +} else := {"effect": "deny", "reason": "identity_refused"} if { + attribute_attempt + not identity_core +} else := {"effect": "deny", "reason": "stale_mfa"} if { + attribute_attempt + stale_mfa +} else := {"effect": "deny", "reason": "identity_refused"} if { + attribute_attempt + not mfa_fresh +} else := {"effect": "deny", "reason": "resource_refused"} if { + attribute_attempt + not resource_ok +} else := {"effect": "deny", "reason": "review_group_not_verified"} if { + attribute_attempt + not review_group_ok +} else := {"effect": "deny", "reason": "unknown_class"} if { + attribute_attempt + not class_ok +} else := {"effect": "deny", "reason": "unknown_submitter"} if { + attribute_attempt + not submitter_ok +} else := {"effect": "deny", "reason": "requester_mismatch"} if { + attribute_attempt + not requester_ok +} else := {"effect": "deny", "reason": "human_control_false"} if { + attribute_attempt + not human_control_ok +} else := {"effect": "deny", "reason": "approval_id_malformed"} if { + attribute_attempt + not approval_id_ok +} else := {"effect": "deny", "reason": "digest_mismatch"} if { + attribute_attempt + not digest_ok +} else := {"effect": "deny", "reason": "memo_version_refused"} if { + attribute_attempt + not version_ok } else := {"effect": "deny", "reason": "compact_sitting_scope_or_identity_refused"} if { true } @@ -297,4 +464,153 @@ test_six_exact_metered_memos_allow_human_review if { } } +attribute_input := { + "tenant": "tenant:platform", + "subject": {"id": "test-human", "type": "human", "tenant": "tenant:platform", + "attributes": {"principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", "groups": ["net-kingdom-admins"], + "assurance": {"level": "aal2", "mfa": true, "source": "key-cape", + "methods": ["pwd", "otp"], "at": time.now_ns() / 1000000000}}}, + "resource": {"tenant": "tenant:platform", "system": "informed-decision", + "type": "decision-memo", "id": "memo:intake-new-memo"}, + "action": "accept", + "context": { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4" + }, +} + +patched_context(req, patch) := object.union(req, {"context": object.union(req.context, patch)}) + +with_at(req, at) := object.union(req, {"subject": object.union(req.subject, {"attributes": object.union(req.subject.attributes, {"assurance": object.union(req.subject.attributes.assurance, {"at": at})})})}) + +eligible(req) := result if { + result := compact_sitting.decision with input as req + with data.flexauth.informed_decision.compact_sitting.admitted_classes as {"credential-lane"} + with data.flexauth.informed_decision.compact_sitting.admitted_submitters as {"state-hub"} +} + +classes_only(req) := result if { + result := compact_sitting.decision with input as req + with data.flexauth.informed_decision.compact_sitting.admitted_classes as {"credential-lane"} +} + +test_shipped_sets_admit_nobody if { + count(compact_sitting.admitted_classes) == 0 + count(compact_sitting.admitted_submitters) == 0 + compact_sitting.decision.reason == "unknown_class" with input as attribute_input +} + +test_attribute_acts_are_eligible_only_when_sets_admit if { + every action in ["read", "acknowledge", "accept", "return", "discuss", "decline"] { + eligible(object.union(attribute_input, {"action": action})).reason == "attribute_eligible" + } +} + +test_allow_does_not_prove_the_pin if { + other := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + attribute_input.context.approval_binding_digest != other + eligible(attribute_input).reason == "attribute_eligible" + eligible(patched_context(attribute_input, {"approval_binding_digest": other})).reason == "attribute_eligible" +} + +test_unknown_class if { + eligible(patched_context(attribute_input, {"memo_class": "not-a-class"})).reason == "unknown_class" +} + +test_unknown_submitter if { + classes_only(attribute_input).reason == "unknown_submitter" +} + +test_requester_mismatch if { + eligible(patched_context(attribute_input, {"approval_requester": "other-service"})).reason == "requester_mismatch" +} + +test_human_control_false if { + eligible(patched_context(attribute_input, {"approval_human_control": false})).reason == "human_control_false" + eligible(patched_context(attribute_input, {"approval_human_control": "true"})).reason == "human_control_false" +} + +test_digest_mismatch if { + eligible(patched_context(attribute_input, {"approval_binding_digest": "sha256:wrong"})).reason == "digest_mismatch" + eligible(patched_context(attribute_input, {"approval_binding_digest": "sha256:C8A1A9C8FB22616E3AACFB877BA7974FEB5EEDAED491F856A6C90719C502EBE4"})).reason == "digest_mismatch" +} + +test_approval_id_malformed if { + eligible(patched_context(attribute_input, {"approval_id": "not-a-uuid"})).reason == "approval_id_malformed" +} + +test_memo_version_refused if { + eligible(patched_context(attribute_input, {"memo_version": 0})).reason == "memo_version_refused" +} + +test_stale_mfa_on_attribute_branch if { + eligible(with_at(attribute_input, time.now_ns() / 1000000000 - 901)).reason == "stale_mfa" +} + +test_future_mfa_is_identity_refused if { + eligible(with_at(attribute_input, time.now_ns() / 1000000000 + 300)).reason == "identity_refused" +} + +test_service_subject_is_identity_refused if { + eligible(object.union(attribute_input, {"subject": object.union(attribute_input.subject, {"type": "service"})})).reason == "identity_refused" +} + +test_review_group_must_be_verified if { + req := object.union(attribute_input, {"subject": object.union(attribute_input.subject, {"attributes": object.union(attribute_input.subject.attributes, {"groups": ["other-group"]})})}) + eligible(req).reason == "review_group_not_verified" +} + +test_memo_review_group_is_not_hardcoded if { + req := object.union(attribute_input, { + "subject": object.union(attribute_input.subject, {"attributes": object.union(attribute_input.subject.attributes, {"groups": ["cabinet-reviewers"]})}), + "context": object.union(attribute_input.context, {"review_group": "cabinet-reviewers"}), + }) + eligible(req).reason == "attribute_eligible" +} + +test_bad_memo_id_refused if { + eligible(object.union(attribute_input, {"resource": object.union(attribute_input.resource, {"id": "memo:NO"})})).reason == "resource_refused" +} + +test_list_does_not_use_attribute_admission if { + got := eligible(object.union(attribute_input, {"action": "list"})) + got.effect == "deny" + got.reason == "compact_sitting_scope_or_identity_refused" +} + +test_exact_record_ignores_attribute_context if { + req := object.union(tool_proof_input, {"context": object.union(tool_proof_input.context, { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "view_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + })}) + got := compact_sitting.decision with input as req + got.effect == "allow" + got.reason == "operator_admitted_compact_sitting" +} + +test_exact_pin_mismatch_is_not_rescued if { + req := object.union(tool_proof_input, {"context": object.union(tool_proof_input.context, { + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + })}) + got := eligible(req) + got.effect == "deny" + got.reason == "compact_sitting_scope_or_identity_refused" +} + ``` diff --git a/internal/decision/attribute_context_test.go b/internal/decision/attribute_context_test.go new file mode 100644 index 0000000..f9d2e77 --- /dev/null +++ b/internal/decision/attribute_context_test.go @@ -0,0 +1,87 @@ +package decision_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/netkingdom/flex-auth/internal/decision" + "github.com/netkingdom/flex-auth/internal/policy" + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestAttributeContextCarriedWithoutViewHash(t *testing.T) { + root := filepath.Join("..", "..") + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join(root, "examples", "informed-decision-sitting", "policy.md")) + if err != nil { + t.Fatalf("LoadAndValidateFile: %v", err) + } + store, err := registry.LoadFile(filepath.Join(root, "examples", "informed-decision-sitting", "registry.json")) + if err != nil { + t.Fatal(err) + } + engine, err := decision.NewEngine(store, pkg) + if err != nil { + t.Fatal(err) + } + request := api.CheckRequest{ + ID: "attribute-context", + Tenant: "tenant:platform", + Subject: api.SubjectRef{ + ID: "synthetic-attribute-reviewer", + Type: "human", + Tenant: "tenant:platform", + Attributes: map[string]any{ + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied", + "groups": []any{"net-kingdom-admins"}, + "assurance": map[string]any{ + "level": "aal2", "mfa": true, "source": "key-cape", + "methods": []any{"pwd", "otp"}, "at": time.Now().Unix(), + }, + }, + }, + Action: "accept", + Resource: api.ResourceRef{ + ID: "memo:intake-new-memo", Type: "decision-memo", + System: "informed-decision", Tenant: "tenant:platform", + }, + Context: map[string]any{ + "review_group": "net-kingdom-admins", + "memo_class": "credential-lane", + "intake_submitter": "state-hub", + "approval_requester": "informed-decision", + "approval_human_control": true, + "memo_version": 1, + "approval_id": "706f4205-aaa7-44f6-be30-d0f57664d69c", + "approval_binding_digest": "sha256:c8a1a9c8fb22616e3aacfb877ba7974feb5eedaed491f856a6c90719c502ebe4", + }, + PolicyVersion: "v8", + } + got, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if got.Effect != api.DecisionEffectDeny || got.Reason != "unknown_class" { + t.Fatalf("effect = %s %s; shipped sets must refuse the class", got.Effect, got.Reason) + } + if got.Binding == nil { + t.Fatal("binding missing") + } + for _, key := range []string{ + "review_group", "memo_class", "intake_submitter", "approval_requester", + "approval_human_control", "memo_version", "approval_id", "approval_binding_digest", + } { + if _, ok := got.Binding.Context[key]; !ok { + t.Fatalf("binding context missing %s", key) + } + } + if _, ok := got.Binding.Context["view_hash"]; ok { + t.Fatal("view_hash entered the decision binding") + } + if got.Binding.Context["approval_requester"] != "informed-decision" || got.Binding.Context["approval_human_control"] != true { + t.Fatalf("binding context = %#v", got.Binding.Context) + } +} diff --git a/internal/policy/informed_decision_attribute_test.go b/internal/policy/informed_decision_attribute_test.go new file mode 100644 index 0000000..d83f041 --- /dev/null +++ b/internal/policy/informed_decision_attribute_test.go @@ -0,0 +1,253 @@ +package policy_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/netkingdom/flex-auth/internal/policy" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestInformedDecisionAttributeAdmission(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), sittingPolicyPath(t)) + if err != nil { + t.Fatalf("LoadAndValidateFile: %v", err) + } + if !pkg.Valid { + t.Fatalf("package invalid: %+v", pkg.Validation) + } + if pkg.Metadata.Version != "v8" { + t.Fatalf("version = %s; want v8", pkg.Metadata.Version) + } + if strings.Contains(pkg.RegoModule, "view_hash") { + t.Fatal("rule module reads view_hash") + } + if !strings.Contains(pkg.RegoModule, "admitted_submitters := set()") || !strings.Contains(pkg.RegoModule, "admitted_classes := set()") { + t.Fatal("shipped admitted sets are not empty") + } + + raw, err := os.ReadFile(filepath.Join(sittingDir(t), "attribute_admission_v8.json")) + if err != nil { + t.Fatal(err) + } + var doc struct { + PolicyVersion string `json:"policy_version"` + Cases []struct { + ID string `json:"id"` + Kind string `json:"kind"` + Assurance string `json:"assurance"` + Request api.CheckRequest `json:"request"` + Expect api.DecisionExpectation `json:"expect"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if doc.PolicyVersion != "v8" { + t.Fatalf("fixture policy_version = %s", doc.PolicyVersion) + } + exactKeys := map[string]bool{"memo_version": true, "approval_id": true, "approval_binding_digest": true} + attributeKeys := map[string]bool{ + "review_group": true, "memo_class": true, "intake_submitter": true, + "approval_requester": true, "approval_human_control": true, "memo_version": true, + "approval_id": true, "approval_binding_digest": true, + } + for _, fixture := range doc.Cases { + request := fixture.Request + stampAssurance(t, &request, fixture.Assurance) + if _, ok := request.Context["view_hash"]; ok { + t.Fatalf("%s carries view_hash", fixture.ID) + } + switch fixture.Kind { + case "exact": + assertKeys(t, fixture.ID, request.Context, exactKeys) + case "attribute": + assertKeys(t, fixture.ID, request.Context, attributeKeys) + default: + t.Fatalf("%s kind %q", fixture.ID, fixture.Kind) + } + got, err := pkg.Evaluate(context.Background(), request) + if err != nil { + t.Fatalf("%s: %v", fixture.ID, err) + } + if got.Effect != fixture.Expect.Effect || got.Reason != fixture.Expect.Reason { + t.Fatalf("%s: got %s %s; want %s %s", fixture.ID, got.Effect, got.Reason, fixture.Expect.Effect, fixture.Expect.Reason) + } + if fixture.ID == "exact-record-allow" { + withHash := request + withHash.Context = copyContext(request.Context) + withHash.Context["view_hash"] = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + hashed, err := pkg.Evaluate(context.Background(), withHash) + if err != nil { + t.Fatal(err) + } + if hashed.Effect != got.Effect || hashed.Reason != got.Reason { + t.Fatalf("view_hash changed exact decision to %s %s", hashed.Effect, hashed.Reason) + } + } + } +} + +func TestSittingImagePinUnchanged(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "values", "informed-decision-sitting.yaml")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.Contains(text, "sha256:f6fa2062f7095e66c7a91f20cd130914b5481d46d9d909339e028f7ecdf0065d") { + t.Fatal("sitting image digest changed") + } + if !strings.Contains(text, "/opt/flex-auth/examples/informed-decision-sitting/policy.md") { + t.Fatal("sitting policy path changed") + } +} + +func TestAdmissionSetGenerator(t *testing.T) { + root := repoRoot(t) + check := exec.Command("python3", "tools/gen_informed_decision_admission.py", "--check") + check.Dir = root + if out, err := check.CombinedOutput(); err != nil { + t.Fatalf("drift check: %v\n%s", err, out) + } + + dir := t.TempDir() + policyPath := filepath.Join(dir, "policy.md") + shipped := sittingPolicyPath(t) + source, err := os.ReadFile(shipped) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(policyPath, source, 0o644); err != nil { + t.Fatal(err) + } + registry := filepath.Join(dir, "submitters.json") + body := `{ + "system:serviceaccount:state-hub:state-hub": { + "submitter_id": "state-hub", + "memo_classes": ["credential-lane", "adr-accept"], + "review_groups": ["net-kingdom-admins"], + "daily_limit": 1 + } + }` + if err := os.WriteFile(registry, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + drift := exec.Command("python3", filepath.Join(root, "tools/gen_informed_decision_admission.py"), + "--source", registry, "--policy", policyPath, "--check") + if err := drift.Run(); err == nil { + t.Fatal("non-empty registry matched the empty shipped sets") + } + after, err := os.ReadFile(policyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, source) { + t.Fatal("--check wrote the policy") + } + + write := exec.Command("python3", filepath.Join(root, "tools/gen_informed_decision_admission.py"), + "--source", registry, "--policy", policyPath) + if out, err := write.CombinedOutput(); err != nil { + t.Fatalf("generate: %v\n%s", err, out) + } + generated, err := os.ReadFile(policyPath) + if err != nil { + t.Fatal(err) + } + text := string(generated) + if !strings.Contains(text, `"adr-accept",`) || !strings.Contains(text, `"credential-lane",`) || !strings.Contains(text, `"state-hub",`) { + t.Fatalf("generated sets missing:\n%s", text) + } + if !bytes.Equal(mustRead(t, shipped), source) { + t.Fatal("generator wrote the shipped policy") + } + again := exec.Command("python3", filepath.Join(root, "tools/gen_informed_decision_admission.py"), + "--source", registry, "--policy", policyPath, "--check") + if out, err := again.CombinedOutput(); err != nil { + t.Fatalf("regenerated policy drifted: %v\n%s", err, out) + } + + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("[]"), 0o644); err != nil { + t.Fatal(err) + } + refused := exec.Command("python3", filepath.Join(root, "tools/gen_informed_decision_admission.py"), + "--source", bad, "--policy", policyPath) + if err := refused.Run(); err == nil { + t.Fatal("malformed registry was accepted") + } + kept, err := os.ReadFile(policyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(kept, generated) { + t.Fatal("refused generate changed the policy") + } +} + +func stampAssurance(t *testing.T, request *api.CheckRequest, mode string) { + t.Helper() + assurance, _ := request.Subject.Attributes["assurance"].(map[string]any) + if assurance == nil { + t.Fatal("assurance missing") + } + switch mode { + case "now": + assurance["at"] = time.Now().Unix() + case "stale": + assurance["at"] = int64(1) + default: + t.Fatalf("assurance mode %q", mode) + } +} + +func assertKeys(t *testing.T, id string, ctx map[string]any, want map[string]bool) { + t.Helper() + if len(ctx) != len(want) { + t.Fatalf("%s context keys = %v", id, ctx) + } + for key := range want { + if _, ok := ctx[key]; !ok { + t.Fatalf("%s missing %s", id, key) + } + } +} + +func copyContext(in map[string]any) map[string]any { + out := make(map[string]any, len(in)+1) + for key, value := range in { + out[key] = value + } + return out +} + +func sittingDir(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), "examples", "informed-decision-sitting") +} + +func sittingPolicyPath(t *testing.T) string { + t.Helper() + return filepath.Join(sittingDir(t), "policy.md") +} + +func repoRoot(t *testing.T) string { + t.Helper() + return filepath.Join("..", "..") +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/tests/stage1.sh b/tests/stage1.sh index 24f1911..1bac5c9 100755 --- a/tests/stage1.sh +++ b/tests/stage1.sh @@ -53,6 +53,11 @@ echo "$fetch_render" | grep -q examples/ops-warden-fetch/policy_package.md \ python3 tools/gen_ops_warden_fetch.py --check >/dev/null 2>&1 || [ ! -f "${HOME}/ops-warden/registry/routing/catalog.yaml" ] \ || { echo "ops-warden-fetch is out of date with the routing catalog; run tools/gen_ops_warden_fetch.py" >&2; exit 1; } +if [ -f "${HOME}/informed-decision/deploy/intake-submitters.json" ]; then + python3 tools/gen_informed_decision_admission.py --check >/dev/null \ + || { echo "informed-decision attribute admission sets drifted; run tools/gen_informed_decision_admission.py" >&2; exit 1; } +fi + canary_render="$(helm template flex-auth-canary charts/flex-auth -f values/stage2-canary.yaml --namespace flex-auth)" if echo "$canary_render" | grep -q tokenreviews; then echo "isolated canary must not create TokenReview RBAC" >&2 diff --git a/tools/exercise_sitting_review_policy.py b/tools/exercise_sitting_review_policy.py index bce6a86..cee1951 100644 --- a/tools/exercise_sitting_review_policy.py +++ b/tools/exercise_sitting_review_policy.py @@ -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'), diff --git a/tools/gen_informed_decision_admission.py b/tools/gen_informed_decision_admission.py new file mode 100644 index 0000000..51a83f5 --- /dev/null +++ b/tools/gen_informed_decision_admission.py @@ -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())