FLEX-WP-0035 T01-T03: application access readback pin
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 58s

Contract accepted by user-engine; FLEX-DEC-2026-019 accepted.
- serve-readback: dedicated pin, no enforcement routes, own caller binding
- CheckRequest.purpose hashed into every digest when set; /v1/check refuses it
- POST /v1/readback/application_access and GET /v1/readback/systems
- optional client_binding on protected systems; readback schemas
- schemaguard implements maxItems
- T05 added: informed-decision needs an application.access rule over
  realm-level claims (its lifecycle rules read authentication_audience)

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:02:31 +02:00
parent b6d6bff85e
commit 8f2ba75e4a
23 changed files with 1314 additions and 44 deletions

View file

@ -58,6 +58,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return runExplain(args[1:], stdout, stderr)
case "serve":
return runServe(args[1:], stdout, stderr)
case "serve-readback":
return runServeReadback(args[1:], stdout, stderr)
case "help", "-h", "--help":
printUsage(stdout)
return 0
@ -456,6 +458,12 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Purpose != "" {
// Only the readback pin issues purpose-bearing decisions
// (FLEX-DEC-2026-019); an enforcement pin never does.
http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest)
return
}
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System})
if err != nil {
writeCallerAuthError(w, err)
@ -627,7 +635,7 @@ func fail(stderr io.Writer, err error) int {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: flex-auth <command> [options]")
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve")
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve, serve-readback")
}
type keyValueFlags []string

167
cmd/flex-auth/readback.go Normal file
View file

@ -0,0 +1,167 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/readback"
"github.com/netkingdom/flex-auth/internal/registry"
)
// readbackBinding is the caller-binding key the readback routes authorize
// against. It is not a protected system, so a principal bound to it can never
// represent a system on any pin's /v1/check (FLEX-WP-0035-T02).
const readbackBinding = "application-readback"
type repeatedFlag []string
func (f *repeatedFlag) String() string { return strings.Join(*f, ",") }
func (f *repeatedFlag) Set(value string) error {
*f = append(*f, value)
return nil
}
// runServeReadback serves the advisory application-access readback. It mounts
// no /v1/check or /v1/batch_check: this pin cannot produce an enforcement
// decision (FLEX-DEC-2026-019).
func runServeReadback(args []string, _, stderr io.Writer) int {
fs := newFlagSet("serve-readback", stderr)
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
logPath := fs.String("log", "", "optional JSONL decision log path")
callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce")
callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience")
callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview")
callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential")
callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle")
readbackCaller := fs.String("readback-caller", "", "authenticated Kubernetes principal admitted to the readback routes")
var registries repeatedFlag
fs.Var(&registries, "registry", "registry snapshot JSON file (repeatable; system ids must not repeat)")
var packages keyValueFlags
fs.Var(&packages, "package", "system=policy package Markdown file (repeatable)")
if err := fs.Parse(args); err != nil {
return 64
}
if len(registries) == 0 || len(packages) == 0 {
fmt.Fprintln(stderr, "serve-readback requires --registry and --package")
return 64
}
bindings := map[string]string{}
if *readbackCaller != "" {
bindings[readbackBinding] = *readbackCaller
}
service, err := buildReadback(context.Background(), registries, packages.StringMap())
if err != nil {
return fail(stderr, err)
}
if *logPath != "" {
service.SetDecisionLog(audit.NewJSONLDecisionLog(*logPath))
}
authenticator, err := buildCallerAuthenticator(
callerauth.Mode(*callerAuthMode),
*callerAudience,
*callerKubernetesURL,
*callerReviewerTokenFile,
*callerCAFile,
bindings,
stderr,
)
if err != nil {
return fail(stderr, err)
}
fmt.Fprintf(stderr, "flex-auth readback serving on http://%s\n", *addr)
if err := http.ListenAndServe(*addr, newReadbackMux(service, authenticator)); err != nil {
return fail(stderr, err)
}
return 0
}
func buildReadback(ctx context.Context, registryPaths []string, packagePaths map[string]string) (*readback.Service, error) {
merged := registry.NewStore()
seen := map[string]string{}
for _, path := range registryPaths {
store, err := registry.LoadFile(path)
if err != nil {
return nil, err
}
snapshot := store.Snapshot()
for _, system := range snapshot.Systems {
if first, dup := seen[system.ID]; dup {
return nil, fmt.Errorf("system %q appears in both %s and %s", system.ID, first, path)
}
seen[system.ID] = path
}
if err := merged.ApplySnapshot(snapshot); err != nil {
return nil, fmt.Errorf("merge %s: %w", path, err)
}
}
loaded := make(map[string]*policy.Package, len(packagePaths))
for system, path := range packagePaths {
pkg, err := policy.LoadAndValidateFile(ctx, path)
if err != nil {
return nil, err
}
loaded[system] = pkg
}
return readback.New(merged, loaded)
}
func newReadbackMux(service *readback.Service, authenticator *callerauth.Authenticator) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
authorize := func(w http.ResponseWriter, r *http.Request) (context.Context, bool) {
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{readbackBinding})
if err != nil {
writeCallerAuthError(w, err)
return nil, false
}
return callerauth.WithRecord(r.Context(), authenticator.Record(identity)), true
}
mux.HandleFunc("/v1/readback/application_access", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request readback.Request
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // no action, resource or purpose can be smuggled in
if err := decoder.Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, ok := authorize(w, r)
if !ok {
return
}
response, err := service.Evaluate(ctx, request)
if errors.Is(err, readback.ErrInvalidRequest) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeHTTP(w, response, err)
})
mux.HandleFunc("/v1/readback/systems", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if _, ok := authorize(w, r); !ok {
return
}
writeHTTP(w, service.Systems(), nil)
})
return mux
}

View file

@ -0,0 +1,300 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/readback"
"github.com/netkingdom/flex-auth/internal/schemaguard"
"github.com/netkingdom/flex-auth/pkg/api"
)
// FLEX-WP-0035: application access readback.
const readbackPrincipal = "system:serviceaccount:user-engine:user-engine"
func readbackPath(parts ...string) string {
return filepath.Join(append([]string{"testdata", "readback"}, parts...)...)
}
func newTestReadback(t *testing.T) *readback.Service {
t.Helper()
service, err := buildReadback(context.Background(),
[]string{readbackPath("demo-app", "registry_snapshot.json"), readbackPath("other-app", "registry_snapshot.json")},
map[string]string{
"demo-app": readbackPath("demo-app", "policy_package.md"),
"other-app": readbackPath("other-app", "policy_package.md"),
})
if err != nil {
t.Fatalf("buildReadback: %v", err)
}
return service
}
func readbackAuthenticator(t *testing.T, principal string) *callerauth.Authenticator {
t.Helper()
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
Username: principal,
Audiences: []string{"flex-auth"},
}}, "flex-auth", map[string]string{readbackBinding: readbackPrincipal}, nil)
if err != nil {
t.Fatal(err)
}
return authenticator
}
func subjectRequest(id string, mfa bool, systems ...string) readback.Request {
return readback.Request{
Tenant: "tenant:platform",
Subject: api.SubjectRef{ID: id, Type: "user", Attributes: map[string]any{
"groups": []any{"app-users"},
"assurance": map[string]any{"level": "aal2", "mfa": mfa},
}},
Systems: systems,
}
}
func TestReadbackRowsInRequestOrderAndNeverOmitted(t *testing.T) {
service := newTestReadback(t)
logPath := filepath.Join(t.TempDir(), "decisions.jsonl")
service.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
response, err := service.Evaluate(context.Background(), subjectRequest("user:alice", true, "other-app", "demo-app", "unknown-app"))
if err != nil {
t.Fatal(err)
}
if response.RegistrySnapshotDigest == "" || len(response.Results) != 3 {
t.Fatalf("response = %+v", response)
}
other, demo, unknown := response.Results[0], response.Results[1], response.Results[2]
// other-app's rule would allow; the package does not declare the action.
if other.System != "other-app" || other.Effect != api.DecisionEffectNotApplicable || other.Reason != readback.ReasonNotOffered || other.DecisionID != "" {
t.Fatalf("undeclared row = %+v", other)
}
if unknown.System != "unknown-app" || unknown.Effect != api.DecisionEffectNotApplicable {
t.Fatalf("unknown row = %+v", unknown)
}
if demo.Effect != api.DecisionEffectAllow || demo.Lifetime == nil || demo.Lifetime.ExpiresAt == "" || demo.PolicyPackageDigest == "" || demo.RevocationVisibility["claims"] != "immediate" {
t.Fatalf("allow row = %+v", demo)
}
// The recorded decision carries purpose readback inside its digests.
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
var recorded api.DecisionEnvelope
if err := json.Unmarshal(bytes.TrimSpace(raw), &recorded); err != nil {
t.Fatalf("decision log: %v", err)
}
if recorded.ID != demo.DecisionID || recorded.Binding.Purpose != api.PurposeReadback || recorded.Binding.Action != readback.ApplicationAccess {
t.Fatalf("recorded binding = %+v", recorded.Binding)
}
enforcement := api.CheckRequest{Subject: recorded.Binding.Subject, Action: recorded.Binding.Action, Resource: recorded.Binding.Resource, Tenant: recorded.Binding.Tenant}
if api.RequestDigest(enforcement) == recorded.Binding.RequestDigest {
t.Fatal("readback request_digest equals the enforcement digest; a PEP could accept it")
}
}
func TestReadbackDeniesWithoutMFA(t *testing.T) {
response, err := newTestReadback(t).Evaluate(context.Background(), subjectRequest("user:bob", false, "demo-app"))
if err != nil {
t.Fatal(err)
}
if got := response.Results[0]; got.Effect != api.DecisionEffectDeny || got.Lifetime != nil && got.Lifetime.ExpiresAt != "" {
t.Fatalf("row = %+v", got)
}
}
func TestReadbackSystemsProjection(t *testing.T) {
systems := newTestReadback(t).Systems()
byID := map[string]readback.System{}
for _, system := range systems.Systems {
byID[system.ID] = system
}
demo, other := byID["demo-app"], byID["other-app"]
if !demo.Readback || demo.ClientBinding == nil || demo.ClientBinding.ClientID != "demo-app" || demo.Name != "Demo App" {
t.Fatalf("demo-app = %+v", demo)
}
if other.Readback || other.ClientBinding != nil {
t.Fatalf("other-app = %+v", other)
}
if systems.RegistrySnapshotDigest == "" {
t.Fatal("projection has no snapshot digest")
}
}
func TestReadbackRefusesDuplicateSystemAcrossRegistries(t *testing.T) {
path := readbackPath("demo-app", "registry_snapshot.json")
_, err := buildReadback(context.Background(), []string{path, path}, map[string]string{"demo-app": readbackPath("demo-app", "policy_package.md")})
if err == nil || !strings.Contains(err.Error(), "appears in both") {
t.Fatalf("err = %v", err)
}
}
func postReadback(t *testing.T, url string, body string) *http.Response {
t.Helper()
request, _ := http.NewRequest(http.MethodPost, url+"/v1/readback/application_access", strings.NewReader(body))
request.Header.Set("Authorization", "Bearer token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
return response
}
func TestReadbackHTTPCallerAndRequestShape(t *testing.T) {
service := newTestReadback(t)
server := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, readbackPrincipal)))
defer server.Close()
valid, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
if got := postReadback(t, server.URL, string(valid)).StatusCode; got != http.StatusOK {
t.Fatalf("admitted caller status = %d", got)
}
tooMany := make([]string, readback.MaxSystems+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("app-%d", i)
}
oversized, _ := json.Marshal(subjectRequest("user:alice", true, tooMany...))
duplicated, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app", "demo-app"))
for name, body := range map[string]string{
"smuggled action": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"action":"admin"}`,
"smuggled purpose": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"purpose":""}`,
"no subject": `{"systems":["demo-app"]}`,
"oversized": string(oversized),
"duplicated": string(duplicated),
} {
if got := postReadback(t, server.URL, body).StatusCode; got != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400", name, got)
}
}
// The readback pin mounts no enforcement route.
for _, route := range []string{"/v1/check", "/v1/batch_check"} {
response, err := http.Post(server.URL+route, "application/json", strings.NewReader(`{}`))
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("%s on readback pin = %d, want 404", route, response.StatusCode)
}
}
wrong := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, "system:serviceaccount:other:other")))
defer wrong.Close()
if got := postReadback(t, wrong.URL, string(valid)).StatusCode; got != http.StatusForbidden {
t.Fatalf("unbound caller status = %d, want 403", got)
}
}
// T02 gate: the readback principal gets 403 on an enforcement pin's /v1/check,
// and an enforcement pin refuses a purpose-bearing request.
func TestReadbackPrincipalCannotCheckOnEnforcementPin(t *testing.T) {
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "")
if err != nil {
t.Fatal(err)
}
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
Username: readbackPrincipal, Audiences: []string{"flex-auth"},
}}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator))
defer server.Close()
body, err := os.ReadFile(opsPath("check_request_allow_adm.json"))
if err != nil {
t.Fatal(err)
}
request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("readback principal on /v1/check = %d, want 403", response.StatusCode)
}
var withPurpose map[string]any
if err := json.Unmarshal(body, &withPurpose); err != nil {
t.Fatal(err)
}
withPurpose["purpose"] = api.PurposeReadback
marked, _ := json.Marshal(withPurpose)
open := httptest.NewServer(newServeMux(engine))
defer open.Close()
response, err = http.Post(open.URL+"/v1/check", "application/json", bytes.NewReader(marked))
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("purpose on /v1/check = %d, want 400", response.StatusCode)
}
}
type failingRecorder struct{}
func (failingRecorder) Append(api.DecisionEnvelope) error { return errors.New("disk full") }
// FLEX-DEC-2026-018 holds on the readback: an allow whose record fails to
// commit is withheld (503, rendered "could not be checked"), never released.
func TestReadbackWithholdsUncommittedAllow(t *testing.T) {
service := newTestReadback(t)
service.SetDecisionLog(failingRecorder{})
server := httptest.NewServer(newReadbackMux(service, callerauth.Disabled()))
defer server.Close()
allow, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
if got := postReadback(t, server.URL, string(allow)).StatusCode; got != http.StatusServiceUnavailable {
t.Fatalf("uncommitted allow = %d, want 503", got)
}
deny, _ := json.Marshal(subjectRequest("user:bob", false, "demo-app"))
if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusOK {
t.Fatalf("uncommitted deny = %d, want 200 (a restriction is never withheld)", got)
}
}
// T03 gate: the wire shapes match the published schemas.
func TestReadbackShapesMatchSchemas(t *testing.T) {
service := newTestReadback(t)
request := subjectRequest("user:alice", true, "demo-app", "other-app")
response, err := service.Evaluate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
for schema, value := range map[string]any{
"readback_request.schema.json": request,
"readback_response.schema.json": response,
"readback_systems.schema.json": service.Systems(),
} {
s, err := schemaguard.Load(filepath.Join("..", "..", "schemas", schema))
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
if problems := s.Validate(doc); len(problems) > 0 {
t.Fatalf("%s: %v\n%s", schema, problems, raw)
}
}
}

View file

@ -0,0 +1,24 @@
- id: fixture:demo-app-access-allow
request:
subject:
id: user:alice
attributes:
groups: [app-users]
assurance: {level: aal2, mfa: true}
action: application.access
resource: {id: demo-app, type: application, system: demo-app}
expect:
effect: allow
reason: app_user
- id: fixture:demo-app-access-deny-no-mfa
request:
subject:
id: user:bob
attributes:
groups: [app-users]
assurance: {level: aal1, mfa: false}
action: application.access
resource: {id: demo-app, type: application, system: demo-app}
expect:
effect: deny
reason: no_matching_rule

View file

@ -0,0 +1,53 @@
---
id: testdata.demo-app
name: readback test package demo-app
namespace: testdata:demo-app
version: v1
status: fixture
package: flexauth.testdata.demo_app
actions:
- application.access
- read
owner: team:platform-security
fixtures:
- policy_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles: [Operator]
organization_relations: [ServiceProvider]
scopes:
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
planes: [Identity]
capabilities: [Use]
exposure_modes: [Metadata]
conditions: [Logged]
restrictions: [PrivilegeEscalationBlocked]
---
# Readback test package (FLEX-WP-0035)
Test-only. Do not copy.
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {"effect": "allow", "reason": "app_user"} if {
input.action == "application.access"
"app-users" in input.subject.attributes.groups
input.subject.attributes.assurance.mfa == true
}
```
```rego test
package flexauth.testdata.demo_app_test
import future.keywords.if
import data.flexauth.testdata.demo_app
test_default_deny if {
demo_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
}
```

View file

@ -0,0 +1,28 @@
{
"systems": [
{
"id": "demo-app",
"name": "Demo App",
"client_binding": {
"issuer": "https://keycape.example/realms/netkingdom",
"client_id": "demo-app"
}
}
],
"subjects": [
{
"id": "demo-app-profile",
"type": "Human",
"display_name": "Readback contract profile (declares caller-supplied keys)",
"groups": [
"app-users"
],
"claims": {
"assurance": {
"level": "aal2",
"mfa": true
}
}
}
]
}

View file

@ -0,0 +1,14 @@
# The rule would allow application.access, but this package does not declare
# the action. The readback must answer not_applicable regardless (T01 §2).
- id: fixture:other-app-undeclared-rule-would-allow
request:
subject:
id: user:alice
attributes:
groups: [app-users]
assurance: {level: aal2, mfa: true}
action: application.access
resource: {id: other-app, type: application, system: other-app}
expect:
effect: allow
reason: app_user

View file

@ -0,0 +1,52 @@
---
id: testdata.other-app
name: readback test package other-app
namespace: testdata:other-app
version: v1
status: fixture
package: flexauth.testdata.other_app
actions:
- read
owner: team:platform-security
fixtures:
- policy_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles: [Operator]
organization_relations: [ServiceProvider]
scopes:
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
planes: [Identity]
capabilities: [Use]
exposure_modes: [Metadata]
conditions: [Logged]
restrictions: [PrivilegeEscalationBlocked]
---
# Readback test package (FLEX-WP-0035)
Test-only. Do not copy.
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {"effect": "allow", "reason": "app_user"} if {
input.action == "application.access"
"app-users" in input.subject.attributes.groups
input.subject.attributes.assurance.mfa == true
}
```
```rego test
package flexauth.testdata.other_app_test
import future.keywords.if
import data.flexauth.testdata.other_app
test_default_deny if {
other_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
}
```

View file

@ -0,0 +1,24 @@
{
"systems": [
{
"id": "other-app",
"name": "Other App"
}
],
"subjects": [
{
"id": "other-app-profile",
"type": "Human",
"display_name": "Readback contract profile (declares caller-supplied keys)",
"groups": [
"app-users"
],
"claims": {
"assurance": {
"level": "aal2",
"mfa": true
}
}
}
]
}