FLEX-WP-0035-T05: informed-decision lifecycle v2 declares application.access
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m5s

- application.access allows only with purpose=readback and the authoring
  human bar; no workload path, no audience or client-scoped claims
- v1 operations unchanged; pin.json moves to v2; live pin stays v1 until T04
- examples/application-readback: readback catalogue with informed-decision's
  key-cape client binding; integration test over the shipped files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 609675@bnt-lap001
Assistant-Session: 4987a36e-eb38-4b10-803c-15a29cd78212
This commit is contained in:
tegwick 2026-10-09 20:10:50 +02:00
parent a4103272e1
commit b5da745063
10 changed files with 207 additions and 12 deletions

View file

@ -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 |

View file

@ -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)
}
}

View file

@ -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

View file

@ -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).

View file

@ -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"
}
}
]
}

View file

@ -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"
}
}
]

View file

@ -1,5 +1,5 @@
{
"package": "informed-decision.lifecycle",
"version": "v1",
"package_digest": "sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827"
"version": "v2",
"package_digest": "sha256:da01e045fc108adec6b489e5d51f7e57366793016c5a64776963780a6aae457c"
}

View file

@ -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"}
}
}
```

View file

@ -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" }},

View file

@ -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