FLEX-WP-0035-T05: informed-decision lifecycle v2 declares application.access
- 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:
parent
a4103272e1
commit
b5da745063
10 changed files with 207 additions and 12 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
examples/application-readback/README.md
Normal file
17
examples/application-readback/README.md
Normal 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).
|
||||
13
examples/application-readback/registry_snapshot.json
Normal file
13
examples/application-readback/registry_snapshot.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"package": "informed-decision.lifecycle",
|
||||
"version": "v1",
|
||||
"package_digest": "sha256:cee6a79f74e81d0d389a2c2e2451a92be3434ea9dfcee4986e8c7e6cfdbb1827"
|
||||
"version": "v2",
|
||||
"package_digest": "sha256:da01e045fc108adec6b489e5d51f7e57366793016c5a64776963780a6aae457c"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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" }},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue