feat(authz): bind decisions to exact actions
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02e47-6aac-7ee1-914d-0584c75d3c81
This commit is contained in:
parent
7323dd1a60
commit
c473f1971d
28 changed files with 644 additions and 12 deletions
9
SCOPE.md
9
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
103
docs/action-bound-authorization-contract.md
Normal file
103
docs/action-bound-authorization-contract.md
Normal 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.
|
||||||
103
examples/caring/action_authorization.json
Normal file
103
examples/caring/action_authorization.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
46
internal/callerauth/tokenreview_test.go
Normal file
46
internal/callerauth/tokenreview_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
66
schemas/action_authorization.schema.json
Normal file
66
schemas/action_authorization.schema.json
Normal 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
37
workplans/ADHOC-2026-08-23.md
Normal file
37
workplans/ADHOC-2026-08-23.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue