diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ef60051..68da8af 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -201,10 +201,11 @@ | task | FLEX-WP-0033-T04 | done | — | workplans/FLEX-WP-0033-informed-decision-lifecycle.md | | task | FLEX-WP-0034-T01 | done | — | workplans/FLEX-WP-0034-informed-decision-review-discovery.md | | task | FLEX-WP-0034-T02 | done | — | workplans/FLEX-WP-0034-informed-decision-review-discovery.md | -| task | FLEX-WP-0035-T01 | progress | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | -| task | FLEX-WP-0035-T02 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | -| task | FLEX-WP-0035-T03 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | +| task | FLEX-WP-0035-T01 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | +| task | FLEX-WP-0035-T02 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | +| task | FLEX-WP-0035-T03 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | | task | FLEX-WP-0035-T04 | wait | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | +| task | FLEX-WP-0035-T05 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | | intake | FLEX-IN-0001 | closed | — | intakes/intakes.md | | intake | FLEX-IN-0002 | closed | — | intakes/intakes.md | | decision | FLEX-DEC-2026-001 | resolved | — | decisions/decisions.md | diff --git a/cmd/flex-auth/readback_test.go b/cmd/flex-auth/readback_test.go index 0d64543..60bebce 100644 --- a/cmd/flex-auth/readback_test.go +++ b/cmd/flex-auth/readback_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/netkingdom/flex-auth/internal/audit" "github.com/netkingdom/flex-auth/internal/callerauth" @@ -298,3 +299,47 @@ func TestReadbackShapesMatchSchemas(t *testing.T) { } } } + +// FLEX-WP-0035-T05: the shipped catalogue and informed-decision lifecycle v2 +// answer the readback the way user-engine will ask it. +func TestReadbackInformedDecisionCatalogue(t *testing.T) { + examples := filepath.Join("..", "..", "examples") + service, err := buildReadback(context.Background(), + []string{ + filepath.Join(examples, "application-readback", "registry_snapshot.json"), + filepath.Join(examples, "informed-decision-lifecycle", "registry.json"), + }, + map[string]string{"informed-decision": filepath.Join(examples, "informed-decision-lifecycle", "policy.md")}) + if err != nil { + t.Fatal(err) + } + subject := func(groups ...any) readback.Request { + return readback.Request{ + Tenant: "tenant:platform", + Subject: api.SubjectRef{ID: "human-one", Type: "human", Tenant: "tenant:platform", Attributes: map[string]any{ + "issuer": "https://kc.coulomb.social", "roles": []any{}, "tenant_roles": []any{}, "scopes": []any{}, "groups": groups, + "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []any{"pwd", "otp"}, "at": time.Now().Unix()}, + }}, + Systems: []string{"informed-decision"}, + } + } + for name, tc := range map[string]struct { + request readback.Request + effect api.DecisionEffect + }{ + "admin": {subject("net-kingdom-admins"), api.DecisionEffectAllow}, + "non-admin": {subject("staff"), api.DecisionEffectDeny}, + } { + response, err := service.Evaluate(context.Background(), tc.request) + if err != nil { + t.Fatal(err) + } + if got := response.Results[0]; got.Effect != tc.effect || got.PolicyPackage != "informed-decision.lifecycle" { + t.Fatalf("%s: %+v", name, got) + } + } + systems := service.Systems().Systems + if len(systems) != 1 || !systems[0].Readback || systems[0].ClientBinding == nil || systems[0].ClientBinding.ClientID != "informed-decision-approver" { + t.Fatalf("catalogue = %+v", systems) + } +} diff --git a/docs/application-access-readback.md b/docs/application-access-readback.md index 8048696..3c72e57 100644 --- a/docs/application-access-readback.md +++ b/docs/application-access-readback.md @@ -90,9 +90,15 @@ their own pins run, plus a merged registry snapshot. every consumer pin, which is exactly the widening constraint 2 forbids. It also makes per-page latency the sum of N pins. -Code needed: `serve` today takes one `--policy`. The readback pin needs a -multi-package mode keyed by `resource.system`, restricted to -`application.access`. +`serve-readback` implements this multi-package mode, keyed by +`resource.system` and restricted to `application.access`. The pin's own +catalogue registry is `examples/application-readback/registry_snapshot.json`. +It lists participants and their `client_binding`, and its README names each +participant's package and registry. + +First participant: `informed-decision`, through `informed-decision.lifecycle` +v2 (`FLEX-WP-0035-T05`). Its `application.access` rule reuses the authoring +`human` bar, requires `purpose == "readback"`, and has no workload path. ## 5. Request and response diff --git a/examples/application-readback/README.md b/examples/application-readback/README.md new file mode 100644 index 0000000..6206baf --- /dev/null +++ b/examples/application-readback/README.md @@ -0,0 +1,17 @@ +# Application access readback catalogue + +The readback pin's own registry (`FLEX-WP-0035`, `FLEX-DEC-2026-019`). It holds +one entry per application that user-engine may list: id, display name, and the +key-cape client its users sign in through. The entries are descriptive only and +never decide an allow. Allow comes from each application's own package, which +must declare `application.access`. + +Participants' registries are loaded alongside this one, and their packages are +loaded at the same files their own pins run: + +| System | Package | Registry | +| --- | --- | --- | +| `informed-decision` | `examples/informed-decision-lifecycle/policy.md` (v2) | `examples/informed-decision-lifecycle/registry.json` | + +`client_binding` sources: `informed-decision/docs/keycape-client-registration.md` +(`client_id`) and `informed-decision/informed_decision/oidc.py` (issuer). diff --git a/examples/application-readback/registry_snapshot.json b/examples/application-readback/registry_snapshot.json new file mode 100644 index 0000000..3f924c3 --- /dev/null +++ b/examples/application-readback/registry_snapshot.json @@ -0,0 +1,13 @@ +{ + "systems": [ + { + "id": "informed-decision", + "name": "Informed Decision", + "description": "Decision memos: authoring, collaboration and human review.", + "client_binding": { + "issuer": "https://kc.coulomb.social", + "client_id": "informed-decision-approver" + } + } + ] +} diff --git a/examples/informed-decision-lifecycle/fixtures.json b/examples/informed-decision-lifecycle/fixtures.json index 3343918..aeaa682 100644 --- a/examples/informed-decision-lifecycle/fixtures.json +++ b/examples/informed-decision-lifecycle/fixtures.json @@ -17,5 +17,42 @@ "expect": { "effect": "deny" } + }, + { + "id": "application-access-without-readback-purpose-denied", + "request": { + "id": "application-access-enforcement", + "tenant": "tenant:platform", + "subject": { + "id": "human-one", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "groups": [ + "net-kingdom-admins" + ], + "assurance": { + "level": "aal2", + "mfa": true, + "source": "key-cape", + "methods": [ + "pwd", + "otp" + ], + "at": 0 + } + } + }, + "action": "application.access", + "resource": { + "id": "informed-decision", + "type": "application", + "system": "informed-decision" + } + }, + "expect": { + "effect": "deny", + "reason": "lifecycle_scope_or_identity_refused" + } } ] diff --git a/examples/informed-decision-lifecycle/pin.json b/examples/informed-decision-lifecycle/pin.json index 08ae27b..d48ef54 100644 --- a/examples/informed-decision-lifecycle/pin.json +++ b/examples/informed-decision-lifecycle/pin.json @@ -1,5 +1,5 @@ { "package": "informed-decision.lifecycle", - "version": "v1", - "package_digest": "sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827" + "version": "v2", + "package_digest": "sha256:da01e045fc108adec6b489e5d51f7e57366793016c5a64776963780a6aae457c" } diff --git a/examples/informed-decision-lifecycle/policy.md b/examples/informed-decision-lifecycle/policy.md index b9dbfb7..2732562 100644 --- a/examples/informed-decision-lifecycle/policy.md +++ b/examples/informed-decision-lifecycle/policy.md @@ -2,11 +2,11 @@ id: informed-decision.lifecycle name: Decision lifecycle authoring and collaboration namespace: informed-decision:decision-memo -version: v1 +version: v2 status: ready package: flexauth.informed_decision.lifecycle allow_ttl: 30s -actions: [lifecycle.register, lifecycle.read, lifecycle.search, lifecycle.revise, lifecycle.source, lifecycle.evaluation, lifecycle.comment, lifecycle.events, lifecycle.packet, lifecycle.publish, lifecycle.request_review, lifecycle.outcomes] +actions: [lifecycle.register, lifecycle.read, lifecycle.search, lifecycle.revise, lifecycle.source, lifecycle.evaluation, lifecycle.comment, lifecycle.events, lifecycle.packet, lifecycle.publish, lifecycle.request_review, lifecycle.outcomes, application.access] owner: flex-auth fixtures: [fixtures.json] caring: @@ -43,6 +43,23 @@ identity. Authentication-derived values still come from the enforced consumer boundary (KeyCape sessions or CA-verified TokenReview). This remains a consumer claim trust boundary; a registry declaration is not independent verification. +## Application access readback (v2) + +v2 adds one action, `application.access`, for the advisory readback that tells +user-engine whether to list informed-decision for a signed-in person +(`FLEX-WP-0035-T05`, `FLEX-DEC-2026-019`). v1's twelve operations are +unchanged. + +The rule is the same `human` bar authoring uses: net-kingdom-admins, KeyCape +AAL2 `pwd`+`otp` within 12 hours, tenant platform. It reads only realm-level +IAM Profile v0.3 claims (groups and normalized `assurance`). It reads no +audience, authorized party or client-scoped role, because the forwarded +claims were issued for user-engine's client. The workload path is excluded: +it turns on `authentication_audience`, and the readback lists applications for +people. It also requires `purpose == "readback"`, so it can never allow on an +enforcement pin, which refuses a purpose-bearing request. The allow grants +nothing inside informed-decision; every operation is still checked above. + ```rego import rego.v1 @@ -168,7 +185,20 @@ operation if { routing.review_group == "net-kingdom-admins" } -decision := {"effect":"allow", "reason":"lifecycle_collaboration"} if { +application_access if { + input.action == "application.access" + input.purpose == "readback" + input.tenant == "tenant:platform" + input.subject.tenant == input.tenant + input.resource.system == "informed-decision" + input.resource.type == "application" + input.resource.id == "informed-decision" + human +} + +decision := {"effect":"allow", "reason":"application_access_readback"} if { + application_access +} else := {"effect":"allow", "reason":"lifecycle_collaboration"} if { common identity operation @@ -183,4 +213,12 @@ import data.flexauth.informed_decision.lifecycle test_empty_denied if { lifecycle.decision.effect == "deny" with input as {} } + +test_application_access_needs_readback_purpose if { + lifecycle.decision.effect == "deny" with input as { + "action": "application.access", "tenant": "tenant:platform", + "subject": {"type": "human", "tenant": "tenant:platform"}, + "resource": {"id": "informed-decision", "type": "application", "system": "informed-decision"} + } +} ``` diff --git a/internal/policy/informed_decision_lifecycle_test.go b/internal/policy/informed_decision_lifecycle_test.go index e436e61..90512e6 100644 --- a/internal/policy/informed_decision_lifecycle_test.go +++ b/internal/policy/informed_decision_lifecycle_test.go @@ -42,11 +42,34 @@ func TestInformedDecisionLifecycle(t *testing.T) { human(d)["type"] = "service" human(d)["attributes"] = map[string]any{"principal_type_source": "authentication-derived", "tenant_source": "registration-supplied", "workload_uid": "verified-uid", "authentication_audience": "informed-decision-lifecycle"} } + // FLEX-WP-0035-T05: the readback asks for the application, not a memo. + app := func(d map[string]any) { + d["action"] = "application.access" + d["purpose"] = "readback" + d["resource"] = map[string]any{"id": "informed-decision", "type": "application", "system": "informed-decision"} + delete(d, "context") + delete(attrs(d), "principal_type_source") + delete(attrs(d), "tenant_source") + } tests := []struct { name string effect string change func(map[string]any) }{ + {"readback application access", "allow", app}, + {"application access without readback purpose", "deny", func(d map[string]any) { app(d); delete(d, "purpose") }}, + {"workload application access", "deny", func(d map[string]any) { hub(d); app(d) }}, + {"application access outside group", "deny", func(d map[string]any) { app(d); attrs(d)["groups"] = []any{"other"} }}, + {"application access stale MFA", "deny", func(d map[string]any) { + app(d) + attrs(d)["assurance"].(map[string]any)["at"] = time.Now().Unix() - 43201 + }}, + {"application access without otp", "deny", func(d map[string]any) { app(d); attrs(d)["assurance"].(map[string]any)["methods"] = []any{"pwd"} }}, + {"application access other application", "deny", func(d map[string]any) { + app(d) + d["resource"].(map[string]any)["id"] = "markitect" + }}, + {"application access foreign tenant", "deny", func(d map[string]any) { app(d); d["tenant"] = "tenant:foreign" }}, {"human registration", "allow", func(d map[string]any) {}}, {"workload registration", "allow", hub}, {"foreign workload", "deny", func(d map[string]any) { hub(d); human(d)["id"] = "system:serviceaccount:foreign:foreign" }}, diff --git a/workplans/FLEX-WP-0035-per-identity-application-readback.md b/workplans/FLEX-WP-0035-per-identity-application-readback.md index f53a205..3737673 100644 --- a/workplans/FLEX-WP-0035-per-identity-application-readback.md +++ b/workplans/FLEX-WP-0035-per-identity-application-readback.md @@ -124,8 +124,11 @@ and `readback_systems` are checked against live service output ```task id: FLEX-WP-0035-T05 -status: todo +status: wait priority: high +blocked_on: message-from:informed-decision +blocking_reason: "Source side done; informed-decision must agree to lifecycle v2 (its own lifecycle pin will run the same digest at the next rollout)." +state_hub_task_id: "701cbd99-1a84-5290-8f66-15fa7f407d3b" ``` informed-decision is the first participant. Its `informed-decision.lifecycle` @@ -138,6 +141,18 @@ informed-decision, because its own pin must run the same digest. Add Gate: package valid with allow/deny fixtures, informed-decision agrees, and the rule reads no audience, authorized-party or client-scoped claim. +2026-10-09 source done. `informed-decision.lifecycle` is now v2 +(`sha256:da01e045…`, `pin.json` updated). It adds `application.access`, which +requires `purpose == "readback"` and the existing `human` bar: net-kingdom-admins, +KeyCape AAL2 `pwd`+`otp` within 12h, platform tenant. It has no workload path +and reads no audience or client-scoped claim. v1's twelve operations are +unchanged. Eight Go cases cover it, plus a fixture showing that it denies +without the readback purpose. The catalogue `examples/application-readback/` +binds informed-decision to `https://kc.coulomb.social` / +`informed-decision-approver`. `TestReadbackInformedDecisionCatalogue` proves the +readback over the shipped files. The live lifecycle pin still runs v1 (image +`bdac62bc`) until the T04 rollout. Waiting on informed-decision's agreement. + ## Deploy the readback pin ```task