feat(authz): bind decisions to exact actions
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 37s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02e47-6aac-7ee1-914d-0584c75d3c81
This commit is contained in:
tegwick 2026-08-23 13:18:26 +02:00
parent 7323dd1a60
commit c473f1971d
28 changed files with 644 additions and 12 deletions

View file

@ -83,6 +83,15 @@ fixtures), and the delegated PDP/directory adapter shapes (`FLEX-WP-0004`,
Topaz/OpenFGA/OPA/Cedar/Keycloak/Entra tradeoffs documented with at least one Topaz/OpenFGA/OPA/Cedar/Keycloak/Entra tradeoffs documented with at least one
controlled adapter shape) all landed in May 2026. controlled adapter shape) all landed in May 2026.
Standalone decisions include a structured `binding` containing the normalized
subject, action, resource, context, and a full request digest. This makes the
execute-time decision contract inspectable without parsing reason or diagnostic
prose. Durable human approval lifecycle, supersession, and authenticated
multi-approver evidence remain State Hub responsibilities; the proposed
cross-system object is documented in
`schemas/action_authorization.schema.json` and is not yet a deployed State Hub
endpoint.
The **first shipped protected-system consumer is ops-warden**: its opt-in The **first shipped protected-system consumer is ops-warden**: its opt-in
pre-sign gate calls `POST /v1/check` for `resource.type: ssh-certificate`, pre-sign gate calls `POST /v1/check` for `resource.type: ssh-certificate`,
`action: sign` decisions (`examples/ops-warden/`, policy package, allow/deny `action: sign` decisions (`examples/ops-warden/`, policy package, allow/deny

View file

@ -8,6 +8,7 @@
| Kind | ID | Status | Lane | Source | | Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| workplan | ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md |
| workplan | FLEX-WP-0001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | workplan | FLEX-WP-0001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md |
| workplan | FLEX-WP-0002 | completed | — | workplans/FLEX-WP-0002-standalone-policy-as-code-core.md | | workplan | FLEX-WP-0002 | completed | — | workplans/FLEX-WP-0002-standalone-policy-as-code-core.md |
| workplan | FLEX-WP-0003 | completed | — | workplans/FLEX-WP-0003-markitect-consumer-integration.md | | workplan | FLEX-WP-0003 | completed | — | workplans/FLEX-WP-0003-markitect-consumer-integration.md |
@ -19,11 +20,14 @@
| workplan | FLEX-WP-0009 | finished | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md | | workplan | FLEX-WP-0009 | finished | — | workplans/FLEX-WP-0009-user-engine-production-policy-service.md |
| workplan | FLEX-WP-0010 | finished | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md | | workplan | FLEX-WP-0010 | finished | — | workplans/FLEX-WP-0010-tenant-lifecycle-policy-actions.md |
| workplan | FLEX-WP-0011 | finished | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md | | workplan | FLEX-WP-0011 | finished | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md |
| workplan | FLEX-WP-0012 | proposed | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md | | workplan | FLEX-WP-0012 | ready | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md |
| workplan | FLEX-WP-0013 | finished | — | workplans/FLEX-WP-0013-restore-seven-action-tenant-engine-pin.md | | workplan | FLEX-WP-0013 | finished | — | workplans/FLEX-WP-0013-restore-seven-action-tenant-engine-pin.md |
| workplan | FLEX-WP-0014 | finished | — | workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md | | workplan | FLEX-WP-0014 | finished | — | workplans/FLEX-WP-0014-tenant-guardrail-policy-actions.md |
| workplan | FLEX-WP-0015 | finished | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md | | workplan | FLEX-WP-0015 | finished | — | workplans/FLEX-WP-0015-tenancy-posture-conformance.md |
| workplan | FLEX-WP-0016 | finished | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md | | workplan | FLEX-WP-0016 | finished | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md |
| workplan | FLEX-WP-0017 | active | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
| task | ADHOC-2026-08-23-T02 | done | — | workplans/ADHOC-2026-08-23.md |
| task | FLEX-WP-0001-T001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | task | FLEX-WP-0001-T001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md |
| task | FLEX-WP-0001-T002 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | task | FLEX-WP-0001-T002 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md |
| task | FLEX-WP-0001-T003 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md | | task | FLEX-WP-0001-T003 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md |
@ -79,7 +83,7 @@
| task | FLEX-WP-0011-T01 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md | | task | FLEX-WP-0011-T01 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md |
| task | FLEX-WP-0011-T02 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md | | task | FLEX-WP-0011-T02 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md |
| task | FLEX-WP-0011-T03 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md | | task | FLEX-WP-0011-T03 | done | — | workplans/FLEX-WP-0011-railiance-staged-promotion-overlay.md |
| task | FLEX-WP-0012-T01 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md | | task | FLEX-WP-0012-T01 | progress | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md |
| task | FLEX-WP-0012-T02 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md | | task | FLEX-WP-0012-T02 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md |
| task | FLEX-WP-0012-T03 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md | | task | FLEX-WP-0012-T03 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md |
| task | FLEX-WP-0012-T04 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md | | task | FLEX-WP-0012-T04 | todo | — | workplans/FLEX-WP-0012-credential-grant-authorization-surface.md |
@ -98,3 +102,8 @@
| task | FLEX-WP-0016-T01 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md | | task | FLEX-WP-0016-T01 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md |
| task | FLEX-WP-0016-T02 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md | | task | FLEX-WP-0016-T02 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md |
| task | FLEX-WP-0016-T03 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md | | task | FLEX-WP-0016-T03 | done | — | workplans/FLEX-WP-0016-ops-warden-incluster-policy-pin.md |
| task | FLEX-WP-0017-T01 | done | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | FLEX-WP-0017-T02 | done | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | FLEX-WP-0017-T03 | wait | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | FLEX-WP-0017-T04 | done | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | FLEX-WP-0017-T05 | wait | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |

View file

@ -22,17 +22,20 @@ Manifests for the two cluster-local flex-auth policy-decision services.
Each file is a three-document manifest: `Deployment`, `Service`, and a Each file is a three-document manifest: `Deployment`, `Service`, and a
default-deny `NetworkPolicy` whose ingress is restricted to the one approved default-deny `NetworkPolicy` whose ingress is restricted to the one approved
consumer workload and which permits no egress. consumer workload. Pins with caller authentication enabled permit TCP egress
to ports 443 and 6443 so TokenReview can reach the Kubernetes API; the rules
have no destination selector, so they permit those ports to any destination.
Pins without caller authentication permit no egress.
### A harmless diff on apply ### A harmless diff on apply
`kubectl apply` reports the two NetworkPolicies as `configured` rather than For a pin with caller authentication disabled, `kubectl apply` may report its
`unchanged`, every time. That is not drift: the manifests carry an explicit NetworkPolicy as `configured` rather than `unchanged`. That is not drift: the
`egress: []`, which the API server normalises away on read. With manifest carries an explicit `egress: []`, which the API server normalises
`policyTypes: [Ingress, Egress]` and no egress rules, deny-all egress holds away on read. With `policyTypes: [Ingress, Egress]` and no egress rules,
either way. The empty list is kept because it states the intent to a reader deny-all egress holds either way. Caller-authenticated pins instead render the
instead of leaving it implicit. Deployments and Services do round-trip as 443/6443 egress rule described above. Deployments and Services otherwise
`unchanged`. round-trip as `unchanged`.
## One image, two deployments ## One image, two deployments

View file

@ -195,7 +195,8 @@ verify expected decisions before activation.
flex-auth must provide `check(subject, action, resource, context)` returning a flex-auth must provide `check(subject, action, resource, context)` returning a
decision envelope with effect, reason, policy version, matched rule, decision envelope with effect, reason, policy version, matched rule,
obligations, diagnostics, and provenance. obligations, diagnostics, provenance, and a structured binding to the exact
normalized subject/action/resource/context request that was evaluated.
### FR7. Batch Authorization Checks ### FR7. Batch Authorization Checks

View file

@ -0,0 +1,103 @@
# Action-bound authorization contract
Status: implemented for execute-time request binding; durable approval storage
requires State Hub support.
Date: 2026-08-23
## Ownership and endpoints
flex-auth owns authorization evaluation and the canonical request/decision
vocabulary. A protected system obtains a current decision from:
```text
POST /v1/check
```
The response now includes `binding`, the exact normalized subject, action,
resource, context, and full SHA-256 request digest evaluated by the policy.
Consumers must compare structured fields; `reason`, `diagnostics`, titles, and
other prose are never an authorization contract.
State Hub owns durable organizational-decision identity, resolution,
supersession, and availability. Its current endpoint is:
```text
GET /decisions/{uuid}
```
That object is not yet sufficient for privileged action execution. As of
2026-08-23 it has lifecycle fields and `superseded_by`, but action details live
only in prose and it records only one free-form `decided_by` value. Production
consumers must not infer action binding or dual control from it.
## Durable object
[`../schemas/action_authorization.schema.json`](../schemas/action_authorization.schema.json)
defines the proposed `ActionAuthorization` storage and transport object. It
combines:
- a canonical UUID and explicit lifecycle status;
- one complete `CheckRequest` containing the actor, action, resource, exact
target attributes, stage, purpose, and other policy inputs;
- a bounded validity window;
- approval count and individual approval evidence;
- the flex-auth `DecisionEnvelope` whose structured binding and digest match
that request.
The schema is generic. For secrets-engine, use this profile:
| Requirement | Canonical field |
| --- | --- |
| catalog id | `request.resource.id` |
| stage | `request.resource.attributes.stage` |
| lifecycle operation | `request.action` |
| exact secret fields | `request.resource.attributes.fields` |
| policy/auth targets | `request.resource.attributes.policy_targets` / `auth_targets` |
| requesting actor | `request.subject` |
| purpose | `request.context.purpose` |
| execution window | `validity.not_before` / `validity.expires_at` |
| dual control | `approvals.required_count` and distinct `entries[].subject_id` |
Array values that express exact targets are sets: producers sort and de-duplicate
them before evaluation; consumers require exact set equality. Wildcards are
policy inputs and should be denied for destructive production actions.
## Required verification
A production consumer may execute only when all of the following hold:
1. The durable object resolves from its canonical authority; an outage fails
the action closed.
2. `status` is `approved`, `superseded_by` is absent, and the current time is
inside the validity window.
3. The caller's proposed action exactly matches `request`, including resource
id, system, stage, target sets, actor, and purpose.
4. `decision.effect` is `allow`; `decision.binding` matches the request and its
digest; the policy package/version are accepted for the deployment.
5. Approval entries contain at least `required_count` distinct authenticated
subjects. Repeated entries from one subject count once.
Local fixtures, CCR labels, workplan ids, prose, and a caller-supplied flex-auth
decision id are provenance only. They cannot unlock a production action.
## Outage and supersession semantics
- State Hub or future authorization-object store unreachable: privileged live
action denied; read-only inspection and dry-run may continue with a clear
non-authoritative status.
- flex-auth unavailable when a fresh decision is required: privileged live
action denied. A previously stored decision is usable only inside its bounded
validity window and only if the authoritative lifecycle object can still be
checked for supersession or revocation.
- `superseded`, `expired`, `revoked`, `denied`, or unknown status: deny.
- `superseded_by` present regardless of status: deny and resolve the replacement
explicitly; never follow it silently during execution.
## Remaining external work
State Hub needs a structured object/endpoint capable of storing this schema or
equivalent fields, authenticated approval entries, and atomic supersession.
Until that lands, secrets-engine is correct to keep live destructive actions
disabled. The standalone evaluator and the Topaz, relationship, rule, and
Keycloak delegated adapter boundaries now all populate the same binding.

View file

@ -0,0 +1,103 @@
{
"schema_version": "0.1",
"id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8",
"status": "approved",
"request": {
"id": "check:secrets-engine-destroy-example",
"subject": {
"id": "user:alice",
"type": "Human"
},
"action": "destroy",
"resource": {
"id": "catalog:example-build-test-token",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "build",
"fields": ["token"],
"policy_targets": [],
"auth_targets": []
}
},
"context": {
"purpose": "contract-test"
}
},
"validity": {
"not_before": "2026-08-23T10:00:00Z",
"expires_at": "2026-08-23T10:15:00Z"
},
"approvals": {
"required_count": 2,
"entries": [
{
"subject_id": "user:alice",
"approved_at": "2026-08-23T10:01:00Z",
"assurance": "mfa",
"evidence_ref": "approval:alice"
},
{
"subject_id": "user:bob",
"approved_at": "2026-08-23T10:02:00Z",
"assurance": "mfa",
"evidence_ref": "approval:bob"
}
]
},
"decision": {
"id": "decision:contract-example",
"request_id": "check:secrets-engine-destroy-example",
"effect": "allow",
"reason": "destruction_approved",
"matched_policy_version": "v1",
"matched_rule": "allow_destroy",
"resource": {
"id": "catalog:example-build-test-token",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "build",
"fields": ["token"],
"policy_targets": [],
"auth_targets": []
}
},
"subject": {
"id": "user:alice",
"type": "Human"
},
"binding": {
"subject": {
"id": "user:alice",
"type": "Human"
},
"action": "destroy",
"resource": {
"id": "catalog:example-build-test-token",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "build",
"fields": ["token"],
"policy_targets": [],
"auth_targets": []
}
},
"context": {
"purpose": "contract-test"
},
"request_digest": "sha256:15a07a9ca41df90f0a97568ecb3547bb9a75b8b04bb96a7197e86fe4589c1f45"
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.lifecycle",
"policy_version": "v1",
"decision_time": "2026-08-23T10:00:30Z"
}
},
"provenance": {
"authority": "state-hub"
}
}

View file

@ -16,6 +16,25 @@
"type": "Human", "type": "Human",
"tenant": "tenant:alpha" "tenant": "tenant:alpha"
}, },
"binding": {
"tenant": "tenant:alpha",
"subject": {
"id": "user:alice",
"type": "Human",
"tenant": "tenant:alpha"
},
"action": "read",
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
},
"context": {
"purpose": "project-delivery"
},
"request_digest": "sha256:76ddf09170fc5ac49fb58aa9a6f5fb25bf1eb68b969254553979eb1ee0f15fab"
},
"obligations": [ "obligations": [
{ {
"type": "log_access", "type": "log_access",

View file

@ -144,6 +144,7 @@ func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest,
MatchedRule: reason, MatchedRule: reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: diagnostics, Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName, Evaluator: EvaluatorName,
@ -168,6 +169,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationR
MatchedRule: reason, MatchedRule: reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: failureDiagnostics(authz, kind, err), Diagnostics: failureDiagnostics(authz, kind, err),
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName, Evaluator: EvaluatorName,

View file

@ -86,6 +86,9 @@ func TestAdapterCheckWrapsKeycloakAllow(t *testing.T) {
if got.Effect != api.DecisionEffectAllow || got.Reason != "uma_permission_granted" { if got.Effect != api.DecisionEffectAllow || got.Reason != "uma_permission_granted" {
t.Fatalf("decision = %s/%s", got.Effect, got.Reason) t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
} }
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
}
if got.Provenance.Evaluator != keycloak.EvaluatorName || got.MatchedPolicyVersion != "kc-v2" { if got.Provenance.Evaluator != keycloak.EvaluatorName || got.MatchedPolicyVersion != "kc-v2" {
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion) t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
} }

View file

@ -219,6 +219,7 @@ func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequ
MatchedRule: firstNonEmpty(result.MatchedRule, reason), MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: diagnostics, Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName, Evaluator: EvaluatorName + "/" + a.backendName,
@ -244,6 +245,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCh
MatchedRule: reason, MatchedRule: reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err), Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err),
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName, Evaluator: EvaluatorName + "/" + a.backendName,

View file

@ -73,6 +73,9 @@ func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) {
if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" { if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" {
t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason) t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason)
} }
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
}
if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" { if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" {
t.Fatalf("provenance = %+v", got.Provenance) t.Fatalf("provenance = %+v", got.Provenance)
} }

View file

@ -223,6 +223,7 @@ func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationReques
MatchedRule: firstNonEmpty(result.MatchedRule, reason), MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Obligations: append([]api.Obligation(nil), result.Obligations...), Obligations: append([]api.Obligation(nil), result.Obligations...),
Diagnostics: diagnostics, Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
@ -248,6 +249,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation Evaluatio
MatchedRule: reason, MatchedRule: reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err), Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err),
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName, Evaluator: EvaluatorName + "/" + a.backendName,

View file

@ -83,6 +83,9 @@ func TestAdapterCheckWrapsRuleResult(t *testing.T) {
if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" { if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" {
t.Fatalf("decision = %s/%s", got.Effect, got.Reason) t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
} }
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
}
if got.Provenance.Evaluator != "rule-pdp/opa" || got.MatchedPolicyVersion != "v2" { if got.Provenance.Evaluator != "rule-pdp/opa" || got.MatchedPolicyVersion != "v2" {
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion) t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
} }

View file

@ -190,6 +190,7 @@ func (a *Adapter) envelope(request api.CheckRequest, topazRequest DirectoryCheck
MatchedRule: firstNonEmpty(result.MatchedRule, reason), MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Obligations: append([]api.Obligation(nil), result.Obligations...), Obligations: append([]api.Obligation(nil), result.Obligations...),
Diagnostics: diagnostics, Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
@ -226,6 +227,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, topazRequest Directo
MatchedRule: reason, MatchedRule: reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Diagnostics: diagnostics, Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName, Evaluator: EvaluatorName,

View file

@ -77,6 +77,9 @@ func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) {
if got.Effect != api.DecisionEffectAllow || got.Reason != "topaz_directory_allow" { if got.Effect != api.DecisionEffectAllow || got.Reason != "topaz_directory_allow" {
t.Fatalf("decision = %s/%s; want allow/topaz_directory_allow", got.Effect, got.Reason) t.Fatalf("decision = %s/%s; want allow/topaz_directory_allow", got.Effect, got.Reason)
} }
if got.Binding == nil || got.Binding.Action != "read" || got.Binding.RequestDigest == "" {
t.Fatalf("binding = %+v; want action-bound request", got.Binding)
}
if got.Provenance.Evaluator != topaz.EvaluatorName || got.Provenance.Mode != topaz.DelegatedMode { if got.Provenance.Evaluator != topaz.EvaluatorName || got.Provenance.Mode != topaz.DelegatedMode {
t.Fatalf("provenance = %+v; want delegated Topaz", got.Provenance) t.Fatalf("provenance = %+v; want delegated Topaz", got.Provenance)
} }

View file

@ -98,6 +98,9 @@ func (a *Authenticator) authorize(ctx context.Context, authorization string, sys
} }
identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token)) identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token))
if err != nil { if err != nil {
if errors.Is(err, ErrUnauthenticated) {
return err
}
return fmt.Errorf("%w: %v", ErrUnavailable, err) return fmt.Errorf("%w: %v", ErrUnavailable, err)
} }
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) { if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {

View file

@ -3,6 +3,7 @@ package callerauth
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"testing" "testing"
) )
@ -53,6 +54,11 @@ func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) {
if err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) { if err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("reviewer error = %v; want unavailable", err) t.Fatalf("reviewer error = %v; want unavailable", err)
} }
rejected, _ := New(ModeEnforce, fakeReviewer{err: fmt.Errorf("%w: invalid bearer token", ErrUnauthenticated)}, "flex-auth", bindings, nil)
if err := rejected.Authorize(context.Background(), "Bearer malformed", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("rejected token error = %v; want unauthenticated", err)
}
} }
func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) { func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) {

View file

@ -101,7 +101,7 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string
return Identity{}, fmt.Errorf("decode TokenReview: %w", err) return Identity{}, fmt.Errorf("decode TokenReview: %w", err)
} }
if review.Status.Error != "" { if review.Status.Error != "" {
return Identity{}, fmt.Errorf("TokenReview: %s", review.Status.Error) return Identity{}, fmt.Errorf("%w: TokenReview: %s", ErrUnauthenticated, review.Status.Error)
} }
if !review.Status.Authenticated { if !review.Status.Authenticated {
return Identity{}, nil return Identity{}, nil

View file

@ -0,0 +1,46 @@
package callerauth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestKubernetesTokenReviewerClassifiesRejectedTokenAsUnauthenticated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" {
t.Fatalf("TokenReview path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"status": {
"authenticated": false,
"error": "invalid bearer token"
}
}`))
}))
defer server.Close()
tokenFile := filepath.Join(t.TempDir(), "reviewer-token")
if err := os.WriteFile(tokenFile, []byte("reviewer-token\n"), 0o600); err != nil {
t.Fatal(err)
}
reviewer := &KubernetesTokenReviewer{
endpoint: server.URL + "/apis/authentication.k8s.io/v1/tokenreviews",
audience: "flex-auth",
reviewerTokenFile: tokenFile,
client: server.Client(),
}
_, err := reviewer.Review(context.Background(), "malformed")
if !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("Review error = %v; want unauthenticated", err)
}
}

View file

@ -296,6 +296,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
MatchedRule: expectation.Reason, MatchedRule: expectation.Reason,
Resource: request.Resource, Resource: request.Resource,
Subject: request.Subject, Subject: request.Subject,
Binding: api.NewDecisionBinding(request),
Obligations: expectation.Obligations, Obligations: expectation.Obligations,
Diagnostics: map[string]any{ Diagnostics: map[string]any{
"action": request.Action, "action": request.Action,

View file

@ -49,6 +49,15 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) {
if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" { if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" {
t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource) t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource)
} }
if got.Binding == nil {
t.Fatal("got.Binding is nil")
}
if got.Binding.Action != request.Action || got.Binding.Subject.ID != got.Subject.ID || got.Binding.Resource.ID != got.Resource.ID {
t.Errorf("got.Binding = %+v; want normalized action, subject, and resource", got.Binding)
}
if !strings.HasPrefix(got.Binding.RequestDigest, "sha256:") || len(got.Binding.RequestDigest) != len("sha256:")+64 {
t.Errorf("got.Binding.RequestDigest = %q; want full SHA-256 digest", got.Binding.RequestDigest)
}
if got.Caring == nil || got.Caring.Descriptor == nil { if got.Caring == nil || got.Caring.Descriptor == nil {
t.Fatal("got.Caring.Descriptor is nil") t.Fatal("got.Caring.Descriptor is nil")
} }

View file

@ -1,5 +1,11 @@
package api package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
// ProtectedSystemManifest describes a system that delegates authorization to // ProtectedSystemManifest describes a system that delegates authorization to
// flex-auth. // flex-auth.
type ProtectedSystemManifest struct { type ProtectedSystemManifest struct {
@ -207,12 +213,91 @@ type DecisionEnvelope struct {
MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"` MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"`
Resource ResourceRef `json:"resource" yaml:"resource"` Resource ResourceRef `json:"resource" yaml:"resource"`
Subject SubjectRef `json:"subject" yaml:"subject"` Subject SubjectRef `json:"subject" yaml:"subject"`
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"` Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"` Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"` Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
} }
// DecisionBinding is the exact normalized authorization request evaluated by
// a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose.
type DecisionBinding struct {
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
}
// NewDecisionBinding returns a stable structured binding for the exact request
// an evaluator consumed.
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
data, _ := json.Marshal(request)
sum := sha256.Sum256(data)
contextCopy := make(map[string]any, len(request.Context))
for key, value := range request.Context {
contextCopy[key] = value
}
return &DecisionBinding{
Tenant: request.Tenant,
Subject: request.Subject,
Action: request.Action,
Resource: request.Resource,
Context: contextCopy,
RequestDigest: "sha256:" + hex.EncodeToString(sum[:]),
}
}
// ActionAuthorizationStatus is the lifecycle state of a durable authorization.
type ActionAuthorizationStatus string
const (
ActionAuthorizationPending ActionAuthorizationStatus = "pending"
ActionAuthorizationApproved ActionAuthorizationStatus = "approved"
ActionAuthorizationDenied ActionAuthorizationStatus = "denied"
ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded"
ActionAuthorizationExpired ActionAuthorizationStatus = "expired"
ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked"
)
// ActionAuthorization joins a durable approval lifecycle to one exact
// flex-auth request and decision. Storage and approval collection remain the
// responsibility of the organizational decision authority.
type ActionAuthorization struct {
SchemaVersion string `json:"schema_version" yaml:"schema_version"`
ID string `json:"id" yaml:"id"`
Status ActionAuthorizationStatus `json:"status" yaml:"status"`
SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"`
Request CheckRequest `json:"request" yaml:"request"`
Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"`
Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"`
Decision DecisionEnvelope `json:"decision" yaml:"decision"`
Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"`
}
// ActionAuthorizationValidity bounds execution of an approved action.
type ActionAuthorizationValidity struct {
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
}
// ActionAuthorizationApprovals declares the approval threshold and evidence.
type ActionAuthorizationApprovals struct {
RequiredCount int `json:"required_count" yaml:"required_count"`
Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"`
}
// ActionAuthorizationApprovalEntry is one authenticated approver's evidence.
type ActionAuthorizationApprovalEntry struct {
SubjectID string `json:"subject_id" yaml:"subject_id"`
ApprovedAt string `json:"approved_at" yaml:"approved_at"`
Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"`
EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"`
}
// Obligation describes a follow-up behavior required by a decision. // Obligation describes a follow-up behavior required by a decision.
type Obligation struct { type Obligation struct {
Type string `json:"type" yaml:"type"` Type string `json:"type" yaml:"type"`

View file

@ -93,6 +93,20 @@ func TestDecisionAndAuditExamplesParse(t *testing.T) {
} }
} }
func TestActionAuthorizationExampleParses(t *testing.T) {
var authorization api.ActionAuthorization
loadJSON(t, filepath.Join("..", "..", "examples", "caring", "action_authorization.json"), &authorization)
if authorization.Status != api.ActionAuthorizationApproved {
t.Fatalf("Status = %q; want approved", authorization.Status)
}
if authorization.Request.Action != "destroy" || authorization.Decision.Binding == nil {
t.Fatalf("authorization is not action-bound: %+v", authorization)
}
if authorization.Approvals.RequiredCount != 2 || len(authorization.Approvals.Entries) != 2 {
t.Fatalf("Approvals = %+v; want two-person approval", authorization.Approvals)
}
}
func TestSchemaFilesAreJSON(t *testing.T) { func TestSchemaFilesAreJSON(t *testing.T) {
schemaDir := filepath.Join("..", "..", "schemas") schemaDir := filepath.Join("..", "..", "schemas")
entries, err := os.ReadDir(schemaDir) entries, err := os.ReadDir(schemaDir)

View file

@ -12,6 +12,7 @@ JSON Schema definitions for flex-auth's canonical artefacts:
- `policy_fixture.schema.json` - `policy_fixture.schema.json`
- `check_request.schema.json` - `check_request.schema.json`
- `decision_envelope.schema.json` - `decision_envelope.schema.json`
- `action_authorization.schema.json`
- `audit_event.schema.json` - `audit_event.schema.json`
Schemas are pinned in `FLEX-WP-0002 P2.1` and validated against Go Schemas are pinned in `FLEX-WP-0002 P2.1` and validated against Go

View file

@ -0,0 +1,66 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/action_authorization.schema.json",
"title": "ActionAuthorization",
"description": "Durable approval envelope for one exact authorization request. This is a storage/transport contract; live evaluation remains POST /v1/check.",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "id", "status", "request", "validity", "approvals", "decision"],
"properties": {
"schema_version": {"const": "0.1"},
"id": {"type": "string", "format": "uuid"},
"status": {
"enum": ["pending", "approved", "denied", "superseded", "expired", "revoked"]
},
"superseded_by": {
"type": ["string", "null"],
"format": "uuid"
},
"request": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json"},
"validity": {"$ref": "#/$defs/validity"},
"approvals": {"$ref": "#/$defs/approvals"},
"decision": {"$ref": "https://flex-auth.netkingdom/schemas/decision_envelope.schema.json"},
"provenance": {"type": "object", "additionalProperties": true}
},
"allOf": [
{
"if": {"properties": {"status": {"const": "superseded"}}},
"then": {"required": ["superseded_by"]}
}
],
"$defs": {
"validity": {
"type": "object",
"additionalProperties": false,
"required": ["expires_at"],
"properties": {
"not_before": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"}
}
},
"approvals": {
"type": "object",
"additionalProperties": false,
"required": ["required_count", "entries"],
"properties": {
"required_count": {"type": "integer", "minimum": 1},
"entries": {
"type": "array",
"uniqueItems": true,
"items": {"$ref": "#/$defs/approval_entry"}
}
}
},
"approval_entry": {
"type": "object",
"additionalProperties": false,
"required": ["subject_id", "approved_at"],
"properties": {
"subject_id": {"type": "string", "minLength": 1},
"approved_at": {"type": "string", "format": "date-time"},
"assurance": {"type": "string", "minLength": 1},
"evidence_ref": {"type": "string", "minLength": 1}
}
}
}
}

View file

@ -14,12 +14,26 @@
"matched_rule": {"type": "string", "minLength": 1}, "matched_rule": {"type": "string", "minLength": 1},
"resource": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/resource_ref"}, "resource": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/resource_ref"},
"subject": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/subject_ref"}, "subject": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/subject_ref"},
"binding": {"$ref": "#/$defs/decision_binding"},
"obligations": {"type": "array", "items": {"$ref": "#/$defs/obligation"}}, "obligations": {"type": "array", "items": {"$ref": "#/$defs/obligation"}},
"diagnostics": {"type": "object", "additionalProperties": true}, "diagnostics": {"type": "object", "additionalProperties": true},
"provenance": {"$ref": "#/$defs/provenance"}, "provenance": {"$ref": "#/$defs/provenance"},
"caring": {"$ref": "#/$defs/caring_decision_metadata"} "caring": {"$ref": "#/$defs/caring_decision_metadata"}
}, },
"$defs": { "$defs": {
"decision_binding": {
"type": "object",
"additionalProperties": false,
"required": ["subject", "action", "resource", "request_digest"],
"properties": {
"tenant": {"type": "string", "minLength": 1},
"subject": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/subject_ref"},
"action": {"type": "string", "minLength": 1},
"resource": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/resource_ref"},
"context": {"type": "object", "additionalProperties": true},
"request_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}
}
},
"obligation": { "obligation": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,

View file

@ -0,0 +1,37 @@
---
id: ADHOC-2026-08-23
type: workplan
title: "Inbound caller-auth and deployment documentation corrections"
domain: infotech
repo: flex-auth
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-08-23"
updated: "2026-08-23"
---
## Classify rejected TokenReview credentials as unauthenticated
```task
id: ADHOC-2026-08-23-T01
status: done
priority: medium
```
Treat a successful TokenReview response that rejects a bearer token as an
authentication failure (HTTP 401), while retaining HTTP 503 for transport,
HTTP, and response-decode failures. Regression coverage exercises the reviewer
and authenticator seam.
## Correct NetworkPolicy egress documentation
```task
id: ADHOC-2026-08-23-T02
status: done
priority: low
```
Document that caller-authenticated pins permit TCP 443 and 6443 without a
destination selector for Kubernetes TokenReview, while pins without caller
authentication retain deny-all egress.

View file

@ -0,0 +1,83 @@
---
id: FLEX-WP-0017
type: workplan
title: "Action-bound authorization and durable approval contract"
domain: infotech
repo: flex-auth
status: active
owner: codex
topic_slug: netkingdom
planning_priority: P1
planning_order: 117
created: "2026-08-23"
updated: "2026-08-23"
---
# FLEX-WP-0017 - Action-bound authorization and durable approval contract
secrets-engine needs production authorization that binds an approval to an
exact action, catalog lane, stage, targets, actor, purpose, validity window,
and distinct approvers. The existing flex-auth decision response and State Hub
decision object each provide only part of that contract.
## Bind execute-time decisions to the evaluated request
```task
id: FLEX-WP-0017-T01
status: done
priority: high
```
Add a structured `binding` to standalone `DecisionEnvelope` responses with the
normalized subject, action, resource, context, and full SHA-256 request digest.
Add schema and regression coverage. Prose remains diagnostic only.
## Define the durable authorization object and semantics
```task
id: FLEX-WP-0017-T02
status: done
priority: high
```
Publish `schemas/action_authorization.schema.json` and
`docs/action-bound-authorization-contract.md`, including exact target mapping,
validity, distinct approvals, supersession, and fail-closed outage semantics.
## Add durable storage and authenticated approval evidence
```task
id: FLEX-WP-0017-T03
status: wait
priority: high
```
State Hub must add a structured endpoint/object equivalent to the published
contract, authenticated approval entries, and atomic supersession. Its current
`/decisions/{uuid}` shape has only prose plus a single free-form `decided_by`.
No flex-auth-local substitute is acceptable because flex-auth does not own the
organizational approval lifecycle.
## Propagate bindings through delegated evaluators
```task
id: FLEX-WP-0017-T04
status: done
priority: medium
```
Populate the same binding in Topaz, relationship, rule, and Keycloak adapter
success and fail-closed responses using the shared canonical constructor.
## Consumer handoff and live destructive-action proof
```task
id: FLEX-WP-0017-T05
status: wait
priority: high
```
After T03, secrets-engine validates the canonical object before every
privileged production action and proves wrong action/lane/stage/targets,
expiry, supersession, outage, insufficient approvals, and duplicate approvers
all fail before any OpenBao call. Live destroy stays disabled until that proof.