diff --git a/SCOPE.md b/SCOPE.md index a82b4ff..02b9748 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 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 pre-sign gate calls `POST /v1/check` for `resource.type: ssh-certificate`, `action: sign` decisions (`examples/ops-warden/`, policy package, allow/deny diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c95db90..4dfc755 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,6 +8,7 @@ | 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-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 | @@ -19,11 +20,14 @@ | 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-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-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-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-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 | @@ -79,7 +83,7 @@ | 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-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-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 | @@ -98,3 +102,8 @@ | 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-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 | diff --git a/deploy/README.md b/deploy/README.md index ea6d018..3564b72 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 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 -`kubectl apply` reports the two NetworkPolicies as `configured` rather than -`unchanged`, every time. That is not drift: the manifests carry an explicit -`egress: []`, which the API server normalises away on read. With -`policyTypes: [Ingress, Egress]` and no egress rules, deny-all egress holds -either way. The empty list is kept because it states the intent to a reader -instead of leaving it implicit. Deployments and Services do round-trip as -`unchanged`. +For a pin with caller authentication disabled, `kubectl apply` may report its +NetworkPolicy as `configured` rather than `unchanged`. That is not drift: the +manifest carries an explicit `egress: []`, which the API server normalises +away on read. With `policyTypes: [Ingress, Egress]` and no egress rules, +deny-all egress holds either way. Caller-authenticated pins instead render the +443/6443 egress rule described above. Deployments and Services otherwise +round-trip as `unchanged`. ## One image, two deployments diff --git a/docs/ProductRequirementsDocument.md b/docs/ProductRequirementsDocument.md index 683f2b2..a67c8d4 100644 --- a/docs/ProductRequirementsDocument.md +++ b/docs/ProductRequirementsDocument.md @@ -195,7 +195,8 @@ verify expected decisions before activation. flex-auth must provide `check(subject, action, resource, context)` returning a 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 diff --git a/docs/action-bound-authorization-contract.md b/docs/action-bound-authorization-contract.md new file mode 100644 index 0000000..163f3a8 --- /dev/null +++ b/docs/action-bound-authorization-contract.md @@ -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. diff --git a/examples/caring/action_authorization.json b/examples/caring/action_authorization.json new file mode 100644 index 0000000..8df937d --- /dev/null +++ b/examples/caring/action_authorization.json @@ -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" + } +} diff --git a/examples/caring/decision_envelope.json b/examples/caring/decision_envelope.json index e1586fd..78b600f 100644 --- a/examples/caring/decision_envelope.json +++ b/examples/caring/decision_envelope.json @@ -16,6 +16,25 @@ "type": "Human", "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": [ { "type": "log_access", diff --git a/internal/adapters/keycloak/adapter.go b/internal/adapters/keycloak/adapter.go index a7eba8a..89d9871 100644 --- a/internal/adapters/keycloak/adapter.go +++ b/internal/adapters/keycloak/adapter.go @@ -144,6 +144,7 @@ func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest, MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName, @@ -168,6 +169,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationR MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: failureDiagnostics(authz, kind, err), Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName, diff --git a/internal/adapters/keycloak/adapter_test.go b/internal/adapters/keycloak/adapter_test.go index 8ea1e86..c80c244 100644 --- a/internal/adapters/keycloak/adapter_test.go +++ b/internal/adapters/keycloak/adapter_test.go @@ -86,6 +86,9 @@ func TestAdapterCheckWrapsKeycloakAllow(t *testing.T) { if got.Effect != api.DecisionEffectAllow || got.Reason != "uma_permission_granted" { 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" { t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion) } diff --git a/internal/adapters/relationship/adapter.go b/internal/adapters/relationship/adapter.go index d24433f..e6b3ab5 100644 --- a/internal/adapters/relationship/adapter.go +++ b/internal/adapters/relationship/adapter.go @@ -219,6 +219,7 @@ func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequ MatchedRule: firstNonEmpty(result.MatchedRule, reason), Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName + "/" + a.backendName, @@ -244,6 +245,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCh MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err), Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName + "/" + a.backendName, diff --git a/internal/adapters/relationship/adapter_test.go b/internal/adapters/relationship/adapter_test.go index 781252b..040575d 100644 --- a/internal/adapters/relationship/adapter_test.go +++ b/internal/adapters/relationship/adapter_test.go @@ -73,6 +73,9 @@ func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) { if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" { 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" { t.Fatalf("provenance = %+v", got.Provenance) } diff --git a/internal/adapters/rule/adapter.go b/internal/adapters/rule/adapter.go index 756f487..8649ed9 100644 --- a/internal/adapters/rule/adapter.go +++ b/internal/adapters/rule/adapter.go @@ -223,6 +223,7 @@ func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationReques MatchedRule: firstNonEmpty(result.MatchedRule, reason), Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Obligations: append([]api.Obligation(nil), result.Obligations...), Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ @@ -248,6 +249,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation Evaluatio MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err), Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName + "/" + a.backendName, diff --git a/internal/adapters/rule/adapter_test.go b/internal/adapters/rule/adapter_test.go index 0d523c9..eb6f661 100644 --- a/internal/adapters/rule/adapter_test.go +++ b/internal/adapters/rule/adapter_test.go @@ -83,6 +83,9 @@ func TestAdapterCheckWrapsRuleResult(t *testing.T) { if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" { 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" { t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion) } diff --git a/internal/adapters/topaz/adapter.go b/internal/adapters/topaz/adapter.go index 0b733f2..2f1708a 100644 --- a/internal/adapters/topaz/adapter.go +++ b/internal/adapters/topaz/adapter.go @@ -190,6 +190,7 @@ func (a *Adapter) envelope(request api.CheckRequest, topazRequest DirectoryCheck MatchedRule: firstNonEmpty(result.MatchedRule, reason), Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Obligations: append([]api.Obligation(nil), result.Obligations...), Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ @@ -226,6 +227,7 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, topazRequest Directo MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName, diff --git a/internal/adapters/topaz/adapter_test.go b/internal/adapters/topaz/adapter_test.go index dce60a4..5b7d154 100644 --- a/internal/adapters/topaz/adapter_test.go +++ b/internal/adapters/topaz/adapter_test.go @@ -77,6 +77,9 @@ func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) { if got.Effect != api.DecisionEffectAllow || got.Reason != "topaz_directory_allow" { 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 { t.Fatalf("provenance = %+v; want delegated Topaz", got.Provenance) } diff --git a/internal/callerauth/auth.go b/internal/callerauth/auth.go index e446df4..ccf7b9c 100644 --- a/internal/callerauth/auth.go +++ b/internal/callerauth/auth.go @@ -98,6 +98,9 @@ func (a *Authenticator) authorize(ctx context.Context, authorization string, sys } identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token)) if err != nil { + if errors.Is(err, ErrUnauthenticated) { + return err + } return fmt.Errorf("%w: %v", ErrUnavailable, err) } if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) { diff --git a/internal/callerauth/auth_test.go b/internal/callerauth/auth_test.go index 2e40384..d789695 100644 --- a/internal/callerauth/auth_test.go +++ b/internal/callerauth/auth_test.go @@ -3,6 +3,7 @@ package callerauth import ( "context" "errors" + "fmt" "strings" "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) { 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) { diff --git a/internal/callerauth/tokenreview.go b/internal/callerauth/tokenreview.go index 48a3e56..be6730e 100644 --- a/internal/callerauth/tokenreview.go +++ b/internal/callerauth/tokenreview.go @@ -101,7 +101,7 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string return Identity{}, fmt.Errorf("decode TokenReview: %w", err) } 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 { return Identity{}, nil diff --git a/internal/callerauth/tokenreview_test.go b/internal/callerauth/tokenreview_test.go new file mode 100644 index 0000000..6abf4a0 --- /dev/null +++ b/internal/callerauth/tokenreview_test.go @@ -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) + } +} diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 3fec7c9..ba7e6cf 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -296,6 +296,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe MatchedRule: expectation.Reason, Resource: request.Resource, Subject: request.Subject, + Binding: api.NewDecisionBinding(request), Obligations: expectation.Obligations, Diagnostics: map[string]any{ "action": request.Action, diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index 00c3e56..804d446 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -49,6 +49,15 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) { if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" { 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 { t.Fatal("got.Caring.Descriptor is nil") } diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index e539706..9163e95 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -1,5 +1,11 @@ package api +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" +) + // ProtectedSystemManifest describes a system that delegates authorization to // flex-auth. type ProtectedSystemManifest struct { @@ -207,12 +213,91 @@ type DecisionEnvelope struct { MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"` Resource ResourceRef `json:"resource" yaml:"resource"` Subject SubjectRef `json:"subject" yaml:"subject"` + Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"` Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"` Provenance DecisionProvenance `json:"provenance" yaml:"provenance"` 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. type Obligation struct { Type string `json:"type" yaml:"type"` diff --git a/pkg/api/canonical_test.go b/pkg/api/canonical_test.go index 03b0e4c..c1786d7 100644 --- a/pkg/api/canonical_test.go +++ b/pkg/api/canonical_test.go @@ -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) { schemaDir := filepath.Join("..", "..", "schemas") entries, err := os.ReadDir(schemaDir) diff --git a/schemas/README.md b/schemas/README.md index 0916994..f01862b 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -12,6 +12,7 @@ JSON Schema definitions for flex-auth's canonical artefacts: - `policy_fixture.schema.json` - `check_request.schema.json` - `decision_envelope.schema.json` +- `action_authorization.schema.json` - `audit_event.schema.json` Schemas are pinned in `FLEX-WP-0002 P2.1` and validated against Go diff --git a/schemas/action_authorization.schema.json b/schemas/action_authorization.schema.json new file mode 100644 index 0000000..4e65ef2 --- /dev/null +++ b/schemas/action_authorization.schema.json @@ -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} + } + } + } +} diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index ce837dc..b78466a 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -14,12 +14,26 @@ "matched_rule": {"type": "string", "minLength": 1}, "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"}, + "binding": {"$ref": "#/$defs/decision_binding"}, "obligations": {"type": "array", "items": {"$ref": "#/$defs/obligation"}}, "diagnostics": {"type": "object", "additionalProperties": true}, "provenance": {"$ref": "#/$defs/provenance"}, "caring": {"$ref": "#/$defs/caring_decision_metadata"} }, "$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": { "type": "object", "additionalProperties": false, diff --git a/workplans/ADHOC-2026-08-23.md b/workplans/ADHOC-2026-08-23.md new file mode 100644 index 0000000..d478bca --- /dev/null +++ b/workplans/ADHOC-2026-08-23.md @@ -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. diff --git a/workplans/FLEX-WP-0017-action-bound-authorization-contract.md b/workplans/FLEX-WP-0017-action-bound-authorization-contract.md new file mode 100644 index 0000000..d4f8124 --- /dev/null +++ b/workplans/FLEX-WP-0017-action-bound-authorization-contract.md @@ -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.