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
This commit is contained in:
parent
d014453a1a
commit
e85f511c4f
8 changed files with 1237 additions and 10 deletions
303
examples/informed-decision-sitting/attribute_admission_v8.json
Normal file
303
examples/informed-decision-sitting/attribute_admission_v8.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
140
examples/informed-decision-sitting/attribute_fixtures.json
Normal file
140
examples/informed-decision-sitting/attribute_fixtures.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
```
|
||||
|
|
|
|||
87
internal/decision/attribute_context_test.go
Normal file
87
internal/decision/attribute_context_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
253
internal/policy/informed_decision_attribute_test.go
Normal file
253
internal/policy/informed_decision_attribute_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
119
tools/gen_informed_decision_admission.py
Normal file
119
tools/gen_informed_decision_admission.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue