diff --git a/examples/informed-decision-sitting/attribute_admission_v8.json b/examples/informed-decision-sitting/attribute_admission_v8.json index 12b24d8..67b98d0 100644 --- a/examples/informed-decision-sitting/attribute_admission_v8.json +++ b/examples/informed-decision-sitting/attribute_admission_v8.json @@ -1,6 +1,6 @@ { "policy_id": "informed-decision.compact-sitting", - "policy_version": "v10", + "policy_version": "v11", "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": [ { diff --git a/examples/informed-decision-sitting/policy.md b/examples/informed-decision-sitting/policy.md index fe19c8e..a10807c 100644 --- a/examples/informed-decision-sitting/policy.md +++ b/examples/informed-decision-sitting/policy.md @@ -2,7 +2,7 @@ id: informed-decision.compact-sitting name: Compact sitting exact-record human review namespace: informed-decision:decision-memo -version: v10 +version: v11 status: ready package: flexauth.informed_decision.compact_sitting allow_ttl: 30s @@ -99,6 +99,14 @@ approvals `974eae4d-782d-41ed-93be-e34433b5fe59`, and `b75752d1-bab6-4cf3-9308-5368c1d5aa91`. Existing exact records, including v9's, stay. The v8 attribute branch still admits nobody. +v11 (2026-10-04, IR-WP-0005-T02) lets the three `list_only_records` be +reviewed. `read`, `acknowledge`, `accept`, `return`, `discuss` and +`decline` allow when the memo id, memo version 3, approval id and binding +digest match those records, with the same 900-second KeyCape bar as the +version-1 act rule. `list` is unchanged. A version other than 3, a foreign +approval id, or a different digest is refused. The version-1 `records` set +is unchanged, and these three ids stay out of it. + `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 @@ -225,8 +233,8 @@ list_only_records := { } } -# listable is the union list may range over. Sitting records are memo version 1, -# as the act rule pins; T03 records carry their own version (3). +# listable is the union list may range over. Sitting records are memo version 1. +# T03 records carry memo version 3 and are acted on only through list_only_records. listable := object.union( {id: object.union(r, {"memo_version": 1}) | some id, r in records}, list_only_records, @@ -356,6 +364,33 @@ decision := {"effect": "allow", "reason": "operator_admitted_compact_sitting"} i input.context.approval_id == record.approval_id input.context.approval_binding_digest == record.binding_digest input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"} +} else := {"effect": "allow", "reason": "operator_admitted_t03_review_act"} if { + input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"} + 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"} + "net-kingdom-admins" in input.subject.attributes.groups + 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 + age := time.now_ns() / 1000000000 - assurance.at + age >= -30 + age <= 900 + input.resource.tenant == "tenant:platform" + input.resource.system == "informed-decision" + input.resource.type == "decision-memo" + record := list_only_records[input.resource.id] + 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": "operator_admitted_overview_list"} if { input.action == "list" input.tenant == "tenant:platform" diff --git a/internal/policy/informed_decision_attribute_test.go b/internal/policy/informed_decision_attribute_test.go index c25bbde..616b04f 100644 --- a/internal/policy/informed_decision_attribute_test.go +++ b/internal/policy/informed_decision_attribute_test.go @@ -23,8 +23,8 @@ func TestInformedDecisionAttributeAdmission(t *testing.T) { if !pkg.Valid { t.Fatalf("package invalid: %+v", pkg.Validation) } - if pkg.Metadata.Version != "v10" { - t.Fatalf("version = %s; want v10", pkg.Metadata.Version) + if pkg.Metadata.Version != "v11" { + t.Fatalf("version = %s; want v11", pkg.Metadata.Version) } if strings.Contains(pkg.RegoModule, "view_hash") { t.Fatal("rule module reads view_hash") @@ -50,7 +50,7 @@ func TestInformedDecisionAttributeAdmission(t *testing.T) { if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } - if doc.PolicyVersion != "v10" { + if doc.PolicyVersion != "v11" { t.Fatalf("fixture policy_version = %s", doc.PolicyVersion) } exactKeys := map[string]bool{"memo_version": true, "approval_id": true, "approval_binding_digest": true} diff --git a/internal/policy/informed_decision_t03_act_test.go b/internal/policy/informed_decision_t03_act_test.go new file mode 100644 index 0000000..81ce60c --- /dev/null +++ b/internal/policy/informed_decision_t03_act_test.go @@ -0,0 +1,130 @@ +package policy_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/netkingdom/flex-auth/internal/policy" + "github.com/netkingdom/flex-auth/pkg/api" +) + +// v11 allows review acts on the three list-only T03 records at memo version 3. +var t03ActRecords = map[string][2]string{ + "memo:SECRETS-WP-0010-T03-apply": {"b5fcbfcc-ad56-456f-bc36-c823052e9917", "sha256:03cc5b37437f14e86b686ce4054f976556334eff3fa260b03e8014ed3d9217e5"}, + "memo:SECRETS-WP-0010-T03-verify": {"4e9ff881-d59f-4143-9155-929afbe8dd43", "sha256:72d9267d038c17107c880ffc9009793ce9c70defbddfe2219628854b811a04b2"}, + "memo:SECRETS-WP-0010-T03-exec": {"a24e0898-b3c5-4e50-85a8-18a2c2aa229e", "sha256:f2cf0fb53b740900756ccde5587c3578fcff44beef2f1edab17b9a198a4033d8"}, +} + +func TestT03ReviewActs(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), sittingPolicyPath(t)) + if err != nil || !pkg.Valid { + t.Fatalf("load: %v %+v", err, pkg) + } + if pkg.Metadata.Version != "v11" { + t.Fatalf("version = %s", pkg.Metadata.Version) + } + + var doc struct { + Cases []struct { + ID string `json:"id"` + Assurance string `json:"assurance"` + Request api.CheckRequest `json:"request"` + } `json:"cases"` + } + raw, err := os.ReadFile(filepath.Join(sittingDir(t), "attribute_admission_v8.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + var exact api.CheckRequest + found := false + for _, c := range doc.Cases { + if c.ID == "exact-record-allow" { + exact = c.Request + stampAssurance(t, &exact, c.Assurance) + found = true + } + } + if !found { + t.Fatal("exact-record-allow template missing") + } + + var shipped map[string]struct { + ApprovalID string `json:"approval_id"` + BindingDigest string `json:"binding_digest"` + MemoVersion int `json:"memo_version"` + } + if err := json.Unmarshal(mustRead(t, filepath.Join(sittingDir(t), "list_only_records.json")), &shipped); err != nil { + t.Fatal(err) + } + + eval := func(name string, req api.CheckRequest, effect, reason string) { + t.Helper() + got, err := pkg.Evaluate(context.Background(), req) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if string(got.Effect) != effect || got.Reason != reason { + t.Fatalf("%s: got %s %s; want %s %s", name, got.Effect, got.Reason, effect, reason) + } + } + + for memo, pin := range t03ActRecords { + row := shipped[memo] + if row.ApprovalID != pin[0] || row.BindingDigest != pin[1] || row.MemoVersion != 3 { + t.Fatalf("list_only pin for %s = %+v", memo, row) + } + for _, action := range []string{"read", "acknowledge", "accept", "return", "discuss", "decline"} { + req := exact + req.Action = action + req.Resource.ID = memo + req.Context = copyContext(exact.Context) + req.Context["memo_version"] = float64(3) + req.Context["approval_id"] = pin[0] + req.Context["approval_binding_digest"] = pin[1] + eval(memo+" "+action, req, "allow", "operator_admitted_t03_review_act") + } + + listed := exact + listed.Action = "list" + listed.Resource.ID = memo + listed.Context = copyContext(exact.Context) + listed.Context["memo_version"] = float64(3) + listed.Context["approval_id"] = pin[0] + listed.Context["approval_binding_digest"] = pin[1] + eval(memo+" list", listed, "allow", "operator_admitted_overview_list") + + old := exact + old.Action = "read" + old.Resource.ID = memo + old.Context = copyContext(exact.Context) + old.Context["memo_version"] = float64(2) + old.Context["approval_id"] = pin[0] + old.Context["approval_binding_digest"] = pin[1] + eval(memo+" version 2", old, "deny", "compact_sitting_scope_or_identity_refused") + + wrong := exact + wrong.Action = "accept" + wrong.Resource.ID = memo + wrong.Context = copyContext(exact.Context) + wrong.Context["memo_version"] = float64(3) + wrong.Context["approval_id"] = pin[0] + wrong.Context["approval_binding_digest"] = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + eval(memo+" wrong digest", wrong, "deny", "compact_sitting_scope_or_identity_refused") + } + + // A version-1 sitting record still uses the original act rule. + kept := exact + kept.Action = "read" + kept.Resource.ID = "memo:emergent-profile-adoption-0-2-6" + kept.Context = copyContext(exact.Context) + kept.Context["memo_version"] = float64(1) + kept.Context["approval_id"] = "05751e09-3a5b-4a41-a4e8-b79e96de6484" + kept.Context["approval_binding_digest"] = "sha256:7843e660694dcd7c806dc02ee0cca05dd3a96488f614b1946291496259a02001" + eval("v9 record", kept, "allow", "operator_admitted_compact_sitting") +}