Finish FLEX-WP-0019 layer-model v0.7 conformance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 57s

Close the remaining PDP obligations: mechanical layer declaration check,
registry-snapshot digest in provenance, explicit allow TTL, per-input-class
freshness deadlines, and the published decision-record contract. Document
the canonical request digest as the §6.4.2 replay test.

Assistant: grok
Assistant-Session: 01a06256-fb71-7102-b3a9-27e6734257d0
This commit is contained in:
tegwick 2026-09-03 23:48:45 +02:00
parent 9689894c15
commit 56940727bf
32 changed files with 1194 additions and 111 deletions

View file

@ -306,27 +306,27 @@ policy service consumer-specific.
## Layer Conformance ## Layer Conformance
flex-auth's conformance state under §11 is **conforming with one declared gap**. flex-auth's conformance state under §11 is **conforming**. The mechanical
It holds no Tooling client, so the §5 shapes do not apply to it, and it is not check is `go run ./tools/check_layer_conformance.go` (wired into `make test`):
PEP-shaped, so it owes no stance map under §6.4. the INTENT.md declaration parses, the layer is in the §3 vocabulary, the
Engine role is PDP, and no Tooling client exists in production Go sources.
It is not PEP-shaped, so it owes no stance map under §6.4.
**The declared gap — registry-snapshot digest in decision provenance (§13).** The former declared gap — registry-snapshot digest in decision provenance —
`DecisionProvenance` carries the evaluator, mode, policy package, policy version, is closed. `DecisionProvenance` carries `registry_snapshot_digest` so a
directory ETag, and decision time, but no digest of the registry snapshot that decision that turned on registry content is replayable from its own record.
supplied resource, subject, and relationship facts. A decision that turned on Revocation visibility deadlines per input class are stated in
registry content cannot be replayed from its own provenance. [`docs/decision-input-freshness.md`](docs/decision-input-freshness.md).
The published decision-record contract is
[`docs/decision-record-contract.md`](docs/decision-record-contract.md)
(`flex-auth.decision-record.v1`). The §6.4.2 replay test is
[`docs/canonical-request-digest.md`](docs/canonical-request-digest.md).
v0.7 §9.7.2 promotes this from housekeeping to a **conformance prerequisite**, Outcome-determining content still belongs in the versioned policy package,
on flex-auth's own argument: a stated visibility deadline for a fact carried by not in registry content — for zone stance, for gate-house's authority
a registry snapshot is unfalsifiable while provenance holds no snapshot digest,
because nobody can determine afterwards which snapshot a decision read. The
deadline and the digest are one gap seen from two sides.
Until it closes, one rule holds and flex-auth applies it to everyone equally,
including itself: **outcome-determining content belongs in the versioned policy
package, not in registry content** — for zone stance, for gate-house's authority
ceilings, for maturity levels, and for flex-auth's own facts. Registry content ceilings, for maturity levels, and for flex-auth's own facts. Registry content
carries membership and identity; the policy package carries effect. carries membership and identity; the policy package carries effect. The
snapshot digest makes that split checkable rather than aspirational.
## Non-Goals ## Non-Goals

View file

@ -4,7 +4,7 @@ PKG := ./...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
LDFLAGS := -X main.version=$(VERSION) LDFLAGS := -X main.version=$(VERSION)
.PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run verify-posture .PHONY: all build test vet lint fmt tidy sbom clean ci overlay-render overlay-dry-run verify-posture check-layer
all: vet lint test build all: vet lint test build
@ -12,9 +12,12 @@ build:
@mkdir -p $(BIN_DIR) @mkdir -p $(BIN_DIR)
go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/flex-auth go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/flex-auth
test: test: check-layer
go test -race $(PKG) go test -race $(PKG)
check-layer:
go run ./tools/check_layer_conformance.go
vet: vet:
go vet $(PKG) go vet $(PKG)

View file

@ -26,7 +26,7 @@
| 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 | finished | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md | | workplan | FLEX-WP-0017 | finished | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| workplan | FLEX-WP-0018 | finished | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md | | workplan | FLEX-WP-0018 | finished | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md |
| workplan | FLEX-WP-0019 | ready | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | workplan | FLEX-WP-0019 | finished | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| workplan | FLEX-WP-0020 | proposed | — | workplans/FLEX-WP-0020-repository-identity-migration.md | | workplan | FLEX-WP-0020 | proposed | — | workplans/FLEX-WP-0020-repository-identity-migration.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 |
@ -109,12 +109,12 @@
| task | FLEX-WP-0017-T05 | cancel | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md | | task | FLEX-WP-0017-T05 | cancel | — | workplans/FLEX-WP-0017-action-bound-authorization-contract.md |
| task | FLEX-WP-0018-T01 | done | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md | | task | FLEX-WP-0018-T01 | done | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md |
| task | FLEX-WP-0018-T02 | done | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md | | task | FLEX-WP-0018-T02 | done | — | workplans/FLEX-WP-0018-inbound-auth-corrections.md |
| task | FLEX-WP-0019-T01 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T01 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0019-T02 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T02 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0019-T03 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T03 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0019-T04 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T04 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0019-T05 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T05 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0019-T06 | todo | — | workplans/FLEX-WP-0019-layer-model-conformance.md | | task | FLEX-WP-0019-T06 | done | — | workplans/FLEX-WP-0019-layer-model-conformance.md |
| task | FLEX-WP-0020-T01 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md | | task | FLEX-WP-0020-T01 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md |
| task | FLEX-WP-0020-T02 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md | | task | FLEX-WP-0020-T02 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md |
| task | FLEX-WP-0020-T03 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md | | task | FLEX-WP-0020-T03 | todo | — | workplans/FLEX-WP-0020-repository-identity-migration.md |

View file

@ -16,8 +16,13 @@ POST /v1/check
The response now includes `binding`, the exact normalized subject, action, The response now includes `binding`, the exact normalized subject, action,
resource, context, and full SHA-256 request digest evaluated by the policy. resource, context, and full SHA-256 request digest evaluated by the policy.
Consumers must compare structured fields; `reason`, `diagnostics`, titles, and How that digest is computed, what is excluded, and when replay is permitted
other prose are never an authorization contract. are in [`canonical-request-digest.md`](canonical-request-digest.md). Consumers
must compare structured fields; `reason`, `diagnostics`, titles, and other
prose are never an authorization contract.
The decision envelope itself is the published PDP contract
[`decision-record-contract.md`](decision-record-contract.md).
`approval-engine` owns the durable approval object, authenticated approval `approval-engine` owns the durable approval object, authenticated approval
evidence, storage, supersession, consumption, and availability. State Hub evidence, storage, supersession, consumption, and availability. State Hub

View file

@ -0,0 +1,98 @@
# Canonical request digest
Status: published
Date: 2026-09-02
Standard: security-layer-model_v0.7 §6.4.2
The digest is the mechanical test for replaying a flex-auth verdict. A consumer
may reuse an allow **iff** a new request produces the same digest **and** the
decision's lifetime still holds. Negative caching of a deny uses the same test.
Constructor: `api.NewDecisionBinding` / `api.RequestDigest`.
Field: `DecisionEnvelope.binding.request_digest`.
Format: `sha256:` plus 64 lowercase hex characters.
## What is hashed
Canonical JSON (Go `encoding/json`, which sorts map keys) of:
| Field | Source |
| --- | --- |
| `tenant` | `CheckRequest.tenant` |
| `subject` | normalized `CheckRequest.subject` |
| `action` | `CheckRequest.action` |
| `resource` | normalized `CheckRequest.resource` |
| `context` | `CheckRequest.context` |
Empty optional maps and omitempty strings are omitted. Attribute maps inside
subject and resource are part of the material once the evaluator has enriched
them from the registry.
**Not hashed:** `id`, `policy_version`, and `caring_context`. The request id is
correlation only. Policy version is recorded in provenance. CARING context is
hashed separately as `provenance.input_claim_digests.caring_context`.
## Normalization
1. Tenant on the request is copied onto subject and resource when those refs
omit it.
2. A registry hit copies type, tenant, and selected attributes onto the refs
the digest sees.
3. Context is a shallow copy. Key order is not significant.
A consumer that re-hashes the **original** unenriched request will not match a
decision that turned on registry attributes. Compare structured `binding`
fields to the proposed action, and treat `request_digest` as the evaluator's
statement of what it hashed. To recompute independently, hash the same
normalized tuple the binding carries (tenant, subject, action, resource,
context).
## Stability
The digest is stable across process restarts for the same normalized tuple.
It is **not** stable across:
- a change to any hashed field, including registry-enriched attributes
- a change to this contract (would require a new `contract_version`)
It does not include the policy package, snapshot, or clock. Those live in
provenance and `lifetime`. Two allows over the same tuple but different
snapshots share a digest and differ in `registry_snapshot_digest`.
## Worked example — permitted retry
Request (after enrichment):
```json
{
"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"}
}
```
The evaluator stores `binding.request_digest` for that tuple and
`lifetime.expires_at` in the future. The same subject, action, resource, and
context presented again before expiry is a permitted retry of that allow.
## Worked example — refused replay
Any of the following refuses replay:
- `action` changes from `read` to `destroy` (digest mismatch)
- `resource.attributes.stage` changes from `build` to `production` (digest mismatch)
- `lifetime.expires_at` is in the past (lifetime does not hold)
- the stored effect is `deny` and the consumer is attempting to treat a cached
refusal as still binding after a policy change — negative cache is valid only
for the same digest **and** while the recorded lifetime would have held; a
new `Check` is required once either fails
Local fixtures, State Hub `/decisions/{uuid}` records, and free-form
`decided_by` values are not this test.

View file

@ -0,0 +1,72 @@
# Decision input freshness
Status: published
Date: 2026-09-02
Standard: security-layer-model_v0.7 §9.7.2
A flex-auth decision is a join over sources that refresh independently. One
number at a PDP would be either a fiction or the worst case. This document
states a **visibility deadline per input class**, with the mechanism that
bounds it.
Provenance identifies which snapshot and package a decision read
(`registry_snapshot_digest`, `policy_package_digest`, `directory_etag`,
`input_claim_digests`). Without those digests a deadline is unfalsifiable.
Operational bound shared by every class: an allow is never valid past
`lifetime.expires_at` (default TTL 15m). That is the consumer-visible ceiling.
The class deadlines below say when a **new Check** is guaranteed to see a
revocation of that class of input.
## Approval-claim freshness
| | |
| --- | --- |
| What is joined | Request-time approval and identity claims on `CheckRequest.context` (and related subject attributes) |
| Mechanism | No PDP-side cache of claims. Each Check evaluates the claims as presented. |
| Deadline | **Immediate** on the next Check (0). A revoked claim is visible as soon as the consumer stops sending it. |
| Cached allow | Bounded by `lifetime`. Replay of a previous allow after the claim is revoked is refused once the digest no longer matches or the TTL ends. |
| Provenance | `input_claim_digests.context` |
flex-auth does not store or mutate the approval object (`approval-engine`).
## Registry snapshot cadence
| | |
| --- | --- |
| What is joined | Protected systems, resources, subjects, groups, teams, tenants, relationship facts |
| Mechanism | In-memory `registry.Store` loaded at process start and by `flex-auth load-registry`. There is no background watch. |
| Deadline | A registry-borne revocation is visible after the operator reloads the snapshot **or** when the current allow TTL ends, whichever is first. On a running process that has not reloaded, the snapshot is otherwise unchanged. |
| Provenance | `registry_snapshot_digest` — SHA-256 of canonical snapshot JSON |
The digest makes the deadline checkable: a later Check whose digest differs
read a different snapshot.
## Policy package activation
| | |
| --- | --- |
| What is joined | The validated Rego-in-Markdown package (`id` / `version` / compiled module) |
| Mechanism | Loaded at process start and by CLI `--policy`. No hot swap. |
| Deadline | A package change is visible after reload **or** when the current allow TTL ends, whichever is first. |
| Provenance | `policy_package`, `policy_version`, `policy_package_digest` |
## Directory ETag
| | |
| --- | --- |
| What is joined | Delegated directory state (Topaz objects/relations, relationship-PDP tuples) and optional group-resolver evidence |
| Mechanism | Topaz returns `DirectoryETag`; relationship adapters return a consistency token. Group resolvers (`Graph`, `SCIM`, `LDAP`, `Keycloak`) attach `Freshness.MaxAge` when configured. Verdicts are not cached by flex-auth. |
| Deadline | **Next Check** against the directory for ETag-bearing adapters (0 relative to the directory's own visibility). For resolvers, the configured `MaxAge`; if `MaxAge` is unset, directory-borne group evidence is unbounded except by the allow TTL. |
| Provenance | `directory_etag`; resolver freshness is subject-attribute evidence, not a second decision |
A stale-directory signal from Topaz fails closed (`topaz_directory_stale`).
## How to read this as a consumer
1. Do not cache a verdict past `lifetime.expires_at`.
2. To know whether a stored allow still names the same facts, compare
`registry_snapshot_digest`, `policy_package_digest`, and `directory_etag`
to a fresh Check.
3. Approval revocation is a claim-class problem: stop presenting the claim,
and do not replay a digest that included it.

View file

@ -0,0 +1,57 @@
# Decision-record contract
Status: published
Contract: `flex-auth.decision-record.v1`
Schema: [`../schemas/decision_envelope.schema.json`](../schemas/decision_envelope.schema.json)
Date: 2026-09-02
This is flex-auth's output artifact under the NetKingdom Security Layer Model
v0.7 §17. Taxonomy holds only the shared field vocabulary. Consumers may rely
on this schema.
A decision record is a `DecisionEnvelope` returned by `POST /v1/check` and the
CLI `check` / `batch-check` / `list-allowed` commands. Standalone evaluation
and every delegated adapter (Topaz, relationship, rule, Keycloak) emit the
same shape.
## Required fields
| Field | Meaning |
| --- | --- |
| `id` | Deterministic decision identifier |
| `effect` | `allow`, `deny`, `redact`, `audit_only`, or `not_applicable` |
| `subject` / `resource` | Normalized refs the evaluator used |
| `provenance` | Who evaluated, over which policy and facts |
## Contract fields consumers may rely on
| Field | Meaning |
| --- | --- |
| `contract_version` | `flex-auth.decision-record.v1` |
| `binding` | Structured subject, action, resource, context, and `request_digest` |
| `lifetime` | **Required on every allow.** A TTL with `not_before` and `expires_at` |
| `provenance.policy_package` / `policy_version` | Named package pin |
| `provenance.policy_package_digest` | SHA-256 of package metadata plus compiled Rego |
| `provenance.registry_snapshot_digest` | SHA-256 of the canonical registry snapshot |
| `provenance.directory_etag` | Directory consistency token when a delegated directory was joined |
| `provenance.input_claim_digests` | SHA-256 per request-time claim class (`context`, `caring_context`) |
| `provenance.decision_time` | UTC timestamp used to compute `lifetime` |
`reason`, `diagnostics`, and CARING prose are not an authorization contract.
## Allow lifetime
Every allow carries `lifetime.kind = ttl`. The duration comes from the policy
package `allow_ttl` field, or from the engine default of `15m` when the package
omits it. A package that declares `allow_ttl: none` (or `0s`) produces a deny
with reason `allow_lifetime_unstated` instead of a standing grant.
Replay is permitted only while `lifetime.expires_at` is still in the future.
See [`canonical-request-digest.md`](canonical-request-digest.md) and
[`decision-input-freshness.md`](decision-input-freshness.md).
## Versioning
This is contract version 1. Additive optional fields may appear. Removing or
redefining a required field requires a new `contract_version` value and a new
schema id.

View file

@ -47,6 +47,7 @@
}, },
"decision": { "decision": {
"id": "decision:contract-example", "id": "decision:contract-example",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-destroy-example", "request_id": "check:secrets-engine-destroy-example",
"effect": "allow", "effect": "allow",
"reason": "destruction_approved", "reason": "destruction_approved",
@ -87,7 +88,13 @@
"context": { "context": {
"purpose": "contract-test" "purpose": "contract-test"
}, },
"request_digest": "sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3" "request_digest": "sha256:eb9d856a060813aebe039f19b82d4be1c4589fa6c0429cf20e15fb115db3ef9a"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-08-23T10:00:30Z",
"expires_at": "2026-08-23T10:15:30Z"
}, },
"provenance": { "provenance": {
"evaluator": "flex-auth/local", "evaluator": "flex-auth/local",

View file

@ -1,5 +1,6 @@
{ {
"id": "decision:tenant-alpha-internal-note", "id": "decision:tenant-alpha-internal-note",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:tenant-alpha-internal-note", "request_id": "check:tenant-alpha-internal-note",
"effect": "allow", "effect": "allow",
"reason": "reader_relation", "reason": "reader_relation",
@ -33,7 +34,13 @@
"context": { "context": {
"purpose": "project-delivery" "purpose": "project-delivery"
}, },
"request_digest": "sha256:76ddf09170fc5ac49fb58aa9a6f5fb25bf1eb68b969254553979eb1ee0f15fab" "request_digest": "sha256:99f772642c55b232a2d2fd3d5ff223cf1bf2c4f68942cd0def7c02cd3e5ae6ae"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-05-17T00:00:00Z",
"expires_at": "2026-05-17T00:15:00Z"
}, },
"obligations": [ "obligations": [
{ {
@ -51,6 +58,11 @@
"mode": "standalone", "mode": "standalone",
"policy_package": "markitect.documents", "policy_package": "markitect.documents",
"policy_version": "v1", "policy_version": "v1",
"policy_package_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"registry_snapshot_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"input_claim_digests": {
"context": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
},
"decision_time": "2026-05-17T00:00:00Z" "decision_time": "2026-05-17T00:00:00Z"
}, },
"caring": { "caring": {

View file

@ -13,11 +13,14 @@ import (
// Adapter maps flex-auth checks and resources to Keycloak Authorization // Adapter maps flex-auth checks and resources to Keycloak Authorization
// Services while preserving the flex-auth decision envelope. // Services while preserving the flex-auth decision envelope.
type Adapter struct { type Adapter struct {
client Client client Client
realm string realm string
audience string audience string
policyPackage string policyPackage string
policyVersion string policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
} }
func New(client Client, options Options) (*Adapter, error) { func New(client Client, options Options) (*Adapter, error) {
@ -31,11 +34,14 @@ func New(client Client, options Options) (*Adapter, error) {
return nil, fmt.Errorf("keycloak audience is required") return nil, fmt.Errorf("keycloak audience is required")
} }
return &Adapter{ return &Adapter{
client: client, client: client,
realm: options.Realm, realm: options.Realm,
audience: options.Audience, audience: options.Audience,
policyPackage: options.PolicyPackage, policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion, policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, nil }, nil
} }
@ -154,7 +160,8 @@ func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest,
}, },
Caring: caringDecisionMetadata(firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings), Caring: caringDecisionMetadata(firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings),
} }
envelope.ID = decisionID(a.policyPackage, policyVersion, request, effect, reason) envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope return envelope
} }
@ -179,10 +186,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationR
}, },
Caring: caringDecisionMetadata(request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}), Caring: caringDecisionMetadata(request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}),
} }
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason) envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope return envelope
} }
func (a *Adapter) finish(request api.CheckRequest, envelope api.DecisionEnvelope) api.DecisionEnvelope {
if envelope.Provenance.RegistrySnapshotDigest == "" {
envelope.Provenance.RegistrySnapshotDigest = a.registrySnapshotDigest
}
if envelope.Provenance.PolicyPackageDigest == "" {
envelope.Provenance.PolicyPackageDigest = a.policyPackageDigest
}
api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: a.allowTTL})
return envelope
}
func caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata { func caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
profile := api.CaringProfileCaring040RC2 profile := api.CaringProfileCaring040RC2
if descriptor != nil && descriptor.Profile != "" { if descriptor != nil && descriptor.Profile != "" {

View file

@ -23,10 +23,13 @@ type Client interface {
// Options configures Keycloak mapping without making Keycloak the source of // Options configures Keycloak mapping without making Keycloak the source of
// truth for flex-auth resources or policies. // truth for flex-auth resources or policies.
type Options struct { type Options struct {
Realm string Realm string
Audience string Audience string
PolicyPackage string PolicyPackage string
PolicyVersion string PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
} }
// AuthorizationRequest is the UMA permission request flex-auth sends to // AuthorizationRequest is the UMA permission request flex-auth sends to

View file

@ -14,10 +14,13 @@ import (
// Adapter wraps tuple-oriented PDP results into flex-auth decision envelopes. // Adapter wraps tuple-oriented PDP results into flex-auth decision envelopes.
type Adapter struct { type Adapter struct {
backend Backend backend Backend
backendName string backendName string
policyPackage string policyPackage string
policyVersion string policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
} }
// New creates a relationship PDP adapter. // New creates a relationship PDP adapter.
@ -30,10 +33,13 @@ func New(backend Backend, options Options) (*Adapter, error) {
backendName = "relationship" backendName = "relationship"
} }
return &Adapter{ return &Adapter{
backend: backend, backend: backend,
backendName: backendName, backendName: backendName,
policyPackage: options.PolicyPackage, policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion, policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, nil }, nil
} }
@ -230,7 +236,8 @@ func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequ
}, },
Caring: caringDecisionMetadata(request, descriptorForResult(request, result), result.ConformanceFindings), Caring: caringDecisionMetadata(request, descriptorForResult(request, result), result.ConformanceFindings),
} }
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, effect, reason, result.ConsistencyToken) envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, result.ConsistencyToken)
return envelope return envelope
} }
@ -255,10 +262,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCh
}, },
Caring: caringDecisionMetadata(request, request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}), Caring: caringDecisionMetadata(request, request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}),
} }
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "") envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "")
return envelope return envelope
} }
func (a *Adapter) finish(request api.CheckRequest, envelope api.DecisionEnvelope) api.DecisionEnvelope {
if envelope.Provenance.RegistrySnapshotDigest == "" {
envelope.Provenance.RegistrySnapshotDigest = a.registrySnapshotDigest
}
if envelope.Provenance.PolicyPackageDigest == "" {
envelope.Provenance.PolicyPackageDigest = a.policyPackageDigest
}
api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: a.allowTTL})
return envelope
}
func descriptorForResult(request api.CheckRequest, result TupleCheckResult) *api.CaringAccessDescriptor { func descriptorForResult(request api.CheckRequest, result TupleCheckResult) *api.CaringAccessDescriptor {
if request.CaringContext != nil { if request.CaringContext != nil {
return request.CaringContext return request.CaringContext

View file

@ -27,9 +27,12 @@ type Backend interface {
// Options configures the relationship adapter without binding callers to a // Options configures the relationship adapter without binding callers to a
// specific backend protocol. // specific backend protocol.
type Options struct { type Options struct {
BackendName string BackendName string
PolicyPackage string PolicyPackage string
PolicyVersion string PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
} }
// Tuple is the canonical relation fact sent to tuple-oriented PDPs. // Tuple is the canonical relation fact sent to tuple-oriented PDPs.

View file

@ -14,12 +14,15 @@ import (
// Adapter wraps rule-PDP responses into flex-auth decision envelopes. // Adapter wraps rule-PDP responses into flex-auth decision envelopes.
type Adapter struct { type Adapter struct {
backend Backend backend Backend
backendName string backendName string
policyPackage string policyPackage string
policyVersion string policyVersion string
language Language policyPackageDigest string
caring api.CaringPolicyMetadata registrySnapshotDigest string
allowTTL string
language Language
caring api.CaringPolicyMetadata
} }
// New creates a delegated rule-PDP adapter. // New creates a delegated rule-PDP adapter.
@ -36,12 +39,15 @@ func New(backend Backend, options Options) (*Adapter, error) {
language = LanguageRego language = LanguageRego
} }
return &Adapter{ return &Adapter{
backend: backend, backend: backend,
backendName: backendName, backendName: backendName,
policyPackage: options.PolicyPackage, policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion, policyVersion: options.PolicyVersion,
language: language, policyPackageDigest: options.PolicyPackageDigest,
caring: options.Caring, registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
language: language,
caring: options.Caring,
}, nil }, nil
} }
@ -234,7 +240,8 @@ func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationReques
}, },
Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), a.caring, result.ConformanceFindings), Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), a.caring, result.ConformanceFindings),
} }
envelope.ID = decisionID(a.backendName, policyPackage, policyVersion, request, effect, reason) envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.backendName, policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope return envelope
} }
@ -259,10 +266,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation Evaluatio
}, },
Caring: caringDecisionMetadata(request, request.CaringContext, a.caring, []api.CaringConformanceFinding{failureFinding(kind)}), Caring: caringDecisionMetadata(request, request.CaringContext, a.caring, []api.CaringConformanceFinding{failureFinding(kind)}),
} }
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason) envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope return envelope
} }
func (a *Adapter) finish(request api.CheckRequest, envelope api.DecisionEnvelope) api.DecisionEnvelope {
if envelope.Provenance.RegistrySnapshotDigest == "" {
envelope.Provenance.RegistrySnapshotDigest = a.registrySnapshotDigest
}
if envelope.Provenance.PolicyPackageDigest == "" {
envelope.Provenance.PolicyPackageDigest = a.policyPackageDigest
}
api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: a.allowTTL})
return envelope
}
func caringDecisionMetadata( func caringDecisionMetadata(
request api.CheckRequest, request api.CheckRequest,
descriptor *api.CaringAccessDescriptor, descriptor *api.CaringAccessDescriptor,

View file

@ -33,11 +33,14 @@ type Backend interface {
// Options configures the rule adapter. // Options configures the rule adapter.
type Options struct { type Options struct {
BackendName string BackendName string
PolicyPackage string PolicyPackage string
PolicyVersion string PolicyVersion string
Language Language PolicyPackageDigest string
Caring api.CaringPolicyMetadata RegistrySnapshotDigest string
AllowTTL string
Language Language
Caring api.CaringPolicyMetadata
} }
// EvaluationRequest is the canonical rule-PDP request. // EvaluationRequest is the canonical rule-PDP request.

View file

@ -16,9 +16,12 @@ import (
// Adapter delegates checks, directory writes, and policy bundle publication to // Adapter delegates checks, directory writes, and policy bundle publication to
// Topaz while preserving flex-auth request and decision contracts. // Topaz while preserving flex-auth request and decision contracts.
type Adapter struct { type Adapter struct {
client Client client Client
policyPackage string policyPackage string
policyVersion string policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
} }
// New creates a Topaz adapter. // New creates a Topaz adapter.
@ -27,9 +30,12 @@ func New(client Client, options Options) (*Adapter, error) {
return nil, fmt.Errorf("topaz client is required") return nil, fmt.Errorf("topaz client is required")
} }
return &Adapter{ return &Adapter{
client: client, client: client,
policyPackage: options.PolicyPackage, policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion, policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, nil }, nil
} }
@ -202,6 +208,7 @@ func (a *Adapter) envelope(request api.CheckRequest, topazRequest DirectoryCheck
}, },
Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings, result.ExposureEvent), Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings, result.ExposureEvent),
} }
envelope = a.finish(request, envelope)
envelope.ID = decisionID(policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, result.DirectoryETag) envelope.ID = decisionID(policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, result.DirectoryETag)
if envelope.Caring != nil && envelope.Caring.ExposureEvent != nil && envelope.Caring.ExposureEvent.ID == "" { if envelope.Caring != nil && envelope.Caring.ExposureEvent != nil && envelope.Caring.ExposureEvent.ID == "" {
envelope.Caring.ExposureEvent.ID = envelope.ID + ":exposure" envelope.Caring.ExposureEvent.ID = envelope.ID + ":exposure"
@ -237,10 +244,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, topazRequest Directo
}, },
Caring: caringDecisionMetadata(request, request.CaringContext, findings, nil), Caring: caringDecisionMetadata(request, request.CaringContext, findings, nil),
} }
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "") envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "")
return envelope return envelope
} }
func (a *Adapter) finish(request api.CheckRequest, envelope api.DecisionEnvelope) api.DecisionEnvelope {
if envelope.Provenance.RegistrySnapshotDigest == "" {
envelope.Provenance.RegistrySnapshotDigest = a.registrySnapshotDigest
}
if envelope.Provenance.PolicyPackageDigest == "" {
envelope.Provenance.PolicyPackageDigest = a.policyPackageDigest
}
api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: a.allowTTL})
return envelope
}
func addTopazDiagnostics(diagnostics map[string]any, request DirectoryCheckRequest, failure string) { func addTopazDiagnostics(diagnostics map[string]any, request DirectoryCheckRequest, failure string) {
diagnostics["adapter"] = "topaz" diagnostics["adapter"] = "topaz"
diagnostics["mode"] = DelegatedMode diagnostics["mode"] = DelegatedMode

View file

@ -86,6 +86,12 @@ func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) {
if got.Provenance.DirectoryETag != "etag:rel-42" { if got.Provenance.DirectoryETag != "etag:rel-42" {
t.Fatalf("DirectoryETag = %q", got.Provenance.DirectoryETag) t.Fatalf("DirectoryETag = %q", got.Provenance.DirectoryETag)
} }
if got.Provenance.RegistrySnapshotDigest != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Fatalf("RegistrySnapshotDigest = %q", got.Provenance.RegistrySnapshotDigest)
}
if got.Lifetime == nil || got.Lifetime.Kind != api.DecisionLifetimeTTL {
t.Fatalf("lifetime = %+v; want default ttl", got.Lifetime)
}
if got.Diagnostics["topaz_object_type"] != "document" || got.Diagnostics["topaz_subject_type"] != "user" { if got.Diagnostics["topaz_object_type"] != "document" || got.Diagnostics["topaz_subject_type"] != "user" {
t.Fatalf("diagnostics = %+v; want Topaz check shape", got.Diagnostics) t.Fatalf("diagnostics = %+v; want Topaz check shape", got.Diagnostics)
} }
@ -201,8 +207,9 @@ func newAdapter(t *testing.T, client *fakeClient) *topaz.Adapter {
t.Helper() t.Helper()
adapter, err := topaz.New(client, topaz.Options{ adapter, err := topaz.New(client, topaz.Options{
PolicyPackage: "markitect.documents.internal-read", PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1", PolicyVersion: "v1",
RegistrySnapshotDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}) })
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)

View file

@ -41,8 +41,11 @@ type BundleSink interface {
// Options configures the adapter without leaking Topaz-specific types into the // Options configures the adapter without leaking Topaz-specific types into the
// public flex-auth API. // public flex-auth API.
type Options struct { type Options struct {
PolicyPackage string PolicyPackage string
PolicyVersion string PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
} }
// DirectoryObject is the Topaz directory object shape used by the REST // DirectoryObject is the Topaz directory object shape used by the REST

View file

@ -10,6 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
"github.com/netkingdom/flex-auth/internal/policy" "github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry" "github.com/netkingdom/flex-auth/internal/registry"
@ -24,6 +25,7 @@ type Engine struct {
mu sync.RWMutex mu sync.RWMutex
history map[string]api.DecisionEnvelope history map[string]api.DecisionEnvelope
log DecisionRecorder log DecisionRecorder
clock func() time.Time
} }
// DecisionRecorder persists decision envelopes. // DecisionRecorder persists decision envelopes.
@ -82,6 +84,23 @@ func (e *Engine) SetDecisionLog(log DecisionRecorder) {
e.log = log e.log = log
} }
// SetClock overrides the engine clock. Tests use this to pin allow lifetimes.
func (e *Engine) SetClock(clock func() time.Time) {
e.mu.Lock()
defer e.mu.Unlock()
e.clock = clock
}
func (e *Engine) now() time.Time {
e.mu.RLock()
clock := e.clock
e.mu.RUnlock()
if clock != nil {
return clock().UTC()
}
return time.Now().UTC()
}
// Check evaluates one subject/action/resource request. // Check evaluates one subject/action/resource request.
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
normalized, facts := e.normalizeRequest(request) normalized, facts := e.normalizeRequest(request)
@ -307,13 +326,19 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
"matched_relationship": facts.matchedRelationship, "matched_relationship": facts.matchedRelationship,
}, },
Provenance: api.DecisionProvenance{ Provenance: api.DecisionProvenance{
Evaluator: "flex-auth/local", Evaluator: "flex-auth/local",
Mode: "standalone", Mode: "standalone",
PolicyPackage: e.policy.Metadata.ID, PolicyPackage: e.policy.Metadata.ID,
PolicyVersion: e.policy.Metadata.Version, PolicyVersion: e.policy.Metadata.Version,
PolicyPackageDigest: e.policy.Digest(),
RegistrySnapshotDigest: e.store.Digest(),
}, },
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings), Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
} }
api.CompleteDecision(&envelope, request, api.DecisionCompletion{
AllowTTL: e.policy.Metadata.AllowTTL,
Now: e.now(),
})
envelope.ID = decisionID(e.policy.Metadata, request, envelope) envelope.ID = decisionID(e.policy.Metadata, request, envelope)
return envelope return envelope
} }

View file

@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -198,6 +199,112 @@ func TestExplainUsesRecordedDecision(t *testing.T) {
} }
} }
func TestCheckRecordsRegistrySnapshotDigest(t *testing.T) {
store := newTestStore(t)
engine := newTestEngineWithStore(t, store)
want := store.Digest()
first, err := engine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if first.Provenance.RegistrySnapshotDigest != want {
t.Fatalf("digest = %q; want %q", first.Provenance.RegistrySnapshotDigest, want)
}
if first.Provenance.PolicyPackageDigest == "" || !strings.HasPrefix(first.Provenance.PolicyPackageDigest, "sha256:") {
t.Fatalf("policy package digest = %q", first.Provenance.PolicyPackageDigest)
}
second, err := engine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check again: %v", err)
}
if second.Provenance.RegistrySnapshotDigest != want {
t.Fatal("two decisions over the same snapshot disagree on digest")
}
if err := store.ImportResourceManifest(api.ResourceManifest{
ID: "markitect-extra",
System: "markitect-tool",
Resources: []api.Resource{
{ID: "document:other-note", Type: "document"},
},
}); err != nil {
t.Fatalf("ImportResourceManifest: %v", err)
}
changedEngine := newTestEngineWithStore(t, store)
changed, err := changedEngine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check after snapshot change: %v", err)
}
if changed.Provenance.RegistrySnapshotDigest == want {
t.Fatal("changed snapshot kept the same digest")
}
}
func TestCheckAllowLifetimes(t *testing.T) {
fixed := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
request := api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
}
defaulted := newTestEngine(t)
defaulted.SetClock(func() time.Time { return fixed })
got, err := defaulted.Check(context.Background(), request)
if err != nil {
t.Fatalf("default Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Lifetime == nil || got.Lifetime.TTL != "15m" {
t.Fatalf("defaulted allow = %+v", got)
}
if got.Lifetime.ExpiresAt != "2026-08-29T12:15:00Z" {
t.Fatalf("default expires_at = %q", got.Lifetime.ExpiresAt)
}
if got.ContractVersion != api.DecisionRecordContractV1 {
t.Fatalf("contract_version = %q", got.ContractVersion)
}
declared := newTestEngineWithTTL(t, "5m")
declared.SetClock(func() time.Time { return fixed })
got, err = declared.Check(context.Background(), request)
if err != nil {
t.Fatalf("declared Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Lifetime == nil || got.Lifetime.TTL != "5m" {
t.Fatalf("declared allow = %+v", got)
}
if got.Lifetime.ExpiresAt != "2026-08-29T12:05:00Z" {
t.Fatalf("declared expires_at = %q", got.Lifetime.ExpiresAt)
}
unstated := newTestEngineWithTTL(t, "none")
unstated.SetClock(func() time.Time { return fixed })
got, err = unstated.Check(context.Background(), request)
if err != nil {
t.Fatalf("unstated Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != api.ReasonAllowLifetimeUnstated {
t.Fatalf("unstated allow = %s/%s; want deny/%s", got.Effect, got.Reason, api.ReasonAllowLifetimeUnstated)
}
if got.Lifetime != nil {
t.Fatalf("unstated deny still has lifetime %+v", got.Lifetime)
}
}
func TestCheckWritesDecisionLog(t *testing.T) { func TestCheckWritesDecisionLog(t *testing.T) {
engine := newTestEngine(t) engine := newTestEngine(t)
log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "decisions.jsonl")) log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "decisions.jsonl"))
@ -240,11 +347,22 @@ func newTestEngine(t *testing.T) *decision.Engine {
func newTestEngineWithStore(t *testing.T, store *registry.Store) *decision.Engine { func newTestEngineWithStore(t *testing.T, store *registry.Store) *decision.Engine {
t.Helper() t.Helper()
return newTestEngineWithStoreAndTTL(t, store, "")
}
func newTestEngineWithTTL(t *testing.T, allowTTL string) *decision.Engine {
t.Helper()
return newTestEngineWithStoreAndTTL(t, newTestStore(t), allowTTL)
}
func newTestEngineWithStoreAndTTL(t *testing.T, store *registry.Store, allowTTL string) *decision.Engine {
t.Helper()
policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md")) policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md"))
if err != nil { if err != nil {
t.Fatalf("LoadAndValidateFile policy: %v", err) t.Fatalf("LoadAndValidateFile policy: %v", err)
} }
policyPackage.Metadata.AllowTTL = allowTTL
engine, err := decision.NewEngine(store, policyPackage) engine, err := decision.NewEngine(store, policyPackage)
if err != nil { if err != nil {
t.Fatalf("NewEngine: %v", err) t.Fatalf("NewEngine: %v", err)

View file

@ -0,0 +1,147 @@
// Package layer asserts the NetKingdom security-layer-model §11 declaration.
package layer
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// Layer vocabulary from security-layer-model_v0.7 §3.
var validLayers = map[string]bool{
"Staff": true,
"Engine": true,
"Tooling": true,
}
// Engine roles from §3.3. An Engine declaration must state one.
var validEngineRoles = map[string]bool{
"PDP": true,
"PIP": true,
}
// Tooling clients are invocations, not mentions. These match import paths and
// argv construction that would actually contact OpenBao/Vault.
var toolingPatterns = []*regexp.Regexp{
regexp.MustCompile(`github\.com/hashicorp/vault`),
regexp.MustCompile(`github\.com/openbao/`),
regexp.MustCompile(`exec\.Command\([^)]*["'](?:bao|vault)["']`),
}
// Declaration is the machine-readable §11 form carried in INTENT.md frontmatter.
type Declaration struct {
Layer string `yaml:"layer"`
Role string `yaml:"role"`
Framework string `yaml:"framework"`
StandardVersion string `yaml:"standard_version"`
DeclaredBy string `yaml:"declared_by"`
DeclaredAt string `yaml:"declared_at"`
PepStance any `yaml:"pep_stance"`
ToolingContacts []any `yaml:"tooling_contacts"`
}
// Check parses INTENT.md, asserts the Engine/PDP declaration, and scans
// production Go sources for undeclared Tooling clients.
func Check(root string) error {
decl, err := LoadDeclaration(filepath.Join(root, "INTENT.md"))
if err != nil {
return err
}
if err := ValidateDeclaration(decl); err != nil {
return err
}
hits, err := ScanToolingClients(root)
if err != nil {
return err
}
if len(hits) > 0 {
return fmt.Errorf("undeclared Tooling client(s) under §11: %s", strings.Join(hits, "; "))
}
return nil
}
// LoadDeclaration reads YAML frontmatter from INTENT.md.
func LoadDeclaration(path string) (Declaration, error) {
data, err := os.ReadFile(path)
if err != nil {
return Declaration{}, fmt.Errorf("read layer declaration: %w", err)
}
frontmatter, err := splitFrontmatter(string(data))
if err != nil {
return Declaration{}, err
}
var decl Declaration
if err := yaml.Unmarshal([]byte(frontmatter), &decl); err != nil {
return Declaration{}, fmt.Errorf("parse layer declaration: %w", err)
}
return decl, nil
}
// ValidateDeclaration asserts §3 vocabulary and Engine-role presence.
func ValidateDeclaration(decl Declaration) error {
if !validLayers[decl.Layer] {
return fmt.Errorf("layer %q is not in the §3 vocabulary (Staff, Engine, Tooling)", decl.Layer)
}
if decl.Layer == "Engine" && !validEngineRoles[decl.Role] {
return fmt.Errorf("Engine declaration must state role PDP or PIP; got %q", decl.Role)
}
if decl.Layer != "Engine" && strings.TrimSpace(decl.Role) != "" {
return fmt.Errorf("layer %q must not state an Engine role", decl.Layer)
}
if len(decl.ToolingContacts) > 0 {
return fmt.Errorf("Engine/PDP holds no Tooling client; tooling_contacts must be empty")
}
if decl.PepStance != nil {
return fmt.Errorf("flex-auth is not PEP-shaped; pep_stance must be null")
}
return nil
}
// ScanToolingClients returns production Go files that invoke OpenBao/Vault.
func ScanToolingClients(root string) ([]string, error) {
var hits []string
for _, dir := range []string{"cmd", "internal", "pkg"} {
err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
for _, pattern := range toolingPatterns {
if pattern.Find(body) != nil {
rel, _ := filepath.Rel(root, path)
hits = append(hits, rel)
break
}
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return nil, err
}
}
return hits, nil
}
func splitFrontmatter(document string) (string, error) {
document = strings.TrimPrefix(document, "\ufeff")
lines := strings.SplitAfter(document, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", fmt.Errorf("INTENT.md must start with YAML frontmatter")
}
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
return strings.Join(lines[1:i], ""), nil
}
}
return "", fmt.Errorf("INTENT.md frontmatter is not closed")
}

View file

@ -0,0 +1,56 @@
package layer_test
import (
"path/filepath"
"runtime"
"testing"
"github.com/netkingdom/flex-auth/internal/layer"
)
func TestLayerDeclarationConforms(t *testing.T) {
root := repoRoot(t)
if err := layer.Check(root); err != nil {
t.Fatalf("layer conformance: %v", err)
}
decl, err := layer.LoadDeclaration(filepath.Join(root, "INTENT.md"))
if err != nil {
t.Fatalf("LoadDeclaration: %v", err)
}
if decl.Layer != "Engine" {
t.Fatalf("layer = %q; want Engine", decl.Layer)
}
if decl.Role != "PDP" {
t.Fatalf("role = %q; want PDP", decl.Role)
}
if decl.Framework != "netkingdom-security-layer-model" {
t.Fatalf("framework = %q", decl.Framework)
}
if decl.StandardVersion != "0.7" {
t.Fatalf("standard_version = %q; want 0.7", decl.StandardVersion)
}
}
func TestEngineWithoutRoleIsRejected(t *testing.T) {
err := layer.ValidateDeclaration(layer.Declaration{Layer: "Engine"})
if err == nil {
t.Fatal("Engine without role was accepted")
}
}
func TestUnknownLayerIsRejected(t *testing.T) {
err := layer.ValidateDeclaration(layer.Declaration{Layer: "ControlPlane", Role: "PDP"})
if err == nil {
t.Fatal("unknown layer was accepted")
}
}
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}

View file

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego" "github.com/open-policy-agent/opa/rego"
@ -148,6 +149,17 @@ func (p *Package) Evaluate(ctx context.Context, request api.CheckRequest) (api.D
return p.evaluateDecision(ctx, request) return p.evaluateDecision(ctx, request)
} }
// Digest is the SHA-256 of canonical metadata plus the compiled Rego module.
func (p *Package) Digest() string {
return api.CanonicalDigest(struct {
Metadata api.PolicyPackageMetadata `json:"metadata"`
RegoModule string `json:"rego_module"`
}{
Metadata: p.Metadata,
RegoModule: p.RegoModule,
})
}
// Validate runs metadata, CARING, OPA parse/test, and fixture validation. // Validate runs metadata, CARING, OPA parse/test, and fixture validation.
func (p *Package) Validate(ctx context.Context) ValidationResult { func (p *Package) Validate(ctx context.Context) ValidationResult {
result := ValidationResult{} result := ValidationResult{}
@ -235,6 +247,24 @@ func (p *Package) metadataDiagnostics() []Diagnostic {
if p.Metadata.Package == "" { if p.Metadata.Package == "" {
diagnostics = append(diagnostics, requiredDiagnostic("POLICY-METADATA-PACKAGE", "package", "OPA package path is required")) diagnostics = append(diagnostics, requiredDiagnostic("POLICY-METADATA-PACKAGE", "package", "OPA package path is required"))
} }
if strings.TrimSpace(p.Metadata.AllowTTL) != "" && !strings.EqualFold(strings.TrimSpace(p.Metadata.AllowTTL), "none") {
ttl, err := time.ParseDuration(strings.TrimSpace(p.Metadata.AllowTTL))
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: "POLICY-ALLOW-TTL-INVALID",
Severity: "error",
Message: fmt.Sprintf("allow_ttl %q is not a Go duration", p.Metadata.AllowTTL),
Fields: []string{"allow_ttl"},
})
} else if ttl < 0 {
diagnostics = append(diagnostics, Diagnostic{
Code: "POLICY-ALLOW-TTL-INVALID",
Severity: "error",
Message: "allow_ttl must be none, 0s, or a positive duration",
Fields: []string{"allow_ttl"},
})
}
}
return diagnostics return diagnostics
} }

View file

@ -137,6 +137,12 @@ func (s *Store) Snapshot() Snapshot {
} }
} }
// Digest is the SHA-256 of the canonical JSON snapshot. Two stores with the
// same records agree; a changed record changes the digest.
func (s *Store) Digest() string {
return api.CanonicalDigest(s.Snapshot())
}
// PutProtectedSystem stores or replaces a protected system manifest. // PutProtectedSystem stores or replaces a protected system manifest.
func (s *Store) PutProtectedSystem(system api.ProtectedSystemManifest) error { func (s *Store) PutProtectedSystem(system api.ProtectedSystemManifest) error {
if system.ID == "" { if system.ID == "" {

View file

@ -3,6 +3,7 @@ package registry_test
import ( import (
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/netkingdom/flex-auth/internal/registry" "github.com/netkingdom/flex-auth/internal/registry"
@ -70,6 +71,33 @@ func TestStoreLoadsAndSavesDeterministicSnapshot(t *testing.T) {
} }
} }
func TestStoreDigestChangesWhenSnapshotChanges(t *testing.T) {
store, err := registry.LoadFile(filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json"))
if err != nil {
t.Fatalf("LoadFile: %v", err)
}
first := store.Digest()
if !strings.HasPrefix(first, "sha256:") || len(first) != len("sha256:")+64 {
t.Fatalf("Digest = %q", first)
}
if store.Digest() != first {
t.Fatal("digest is not stable for an unchanged snapshot")
}
if err := store.ImportResourceManifest(api.ResourceManifest{
ID: "markitect-extra",
System: "markitect-tool",
Resources: []api.Resource{
{ID: "document:other-note", Type: "document"},
},
}); err != nil {
t.Fatalf("ImportResourceManifest: %v", err)
}
if store.Digest() == first {
t.Fatal("digest did not change after snapshot mutation")
}
}
func TestStoreRejectsInvalidRecords(t *testing.T) { func TestStoreRejectsInvalidRecords(t *testing.T) {
store := registry.NewStore() store := registry.NewStore()
if err := store.PutSubject(api.Subject{}); err == nil { if err := store.PutSubject(api.Subject{}); err == nil {

View file

@ -4,6 +4,9 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"strings"
"time"
) )
// ProtectedSystemManifest describes a system that delegates authorization to // ProtectedSystemManifest describes a system that delegates authorization to
@ -118,6 +121,10 @@ type PolicyPackageMetadata struct {
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"` Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"` Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
// AllowTTL is a Go duration (for example "15m") that bounds every allow
// this package produces. Omit to use DefaultAllowTTL. "none" or "0s"
// means no stated end; the engine denies those allows (§9.7.1).
AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"`
} }
// CaringPolicyMetadata declares the CARING envelope a policy governs. // CaringPolicyMetadata declares the CARING envelope a policy governs.
@ -202,10 +209,14 @@ const (
DecisionEffectNotApplicable DecisionEffect = "not_applicable" DecisionEffectNotApplicable DecisionEffect = "not_applicable"
) )
// DecisionRecordContractV1 is the published decision-record contract identifier.
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
// DecisionEnvelope is the stable response produced by standalone and delegated // DecisionEnvelope is the stable response produced by standalone and delegated
// evaluators. // evaluators. It is flex-auth's published decision-record contract (§17).
type DecisionEnvelope struct { type DecisionEnvelope struct {
ID string `json:"id" yaml:"id"` ID string `json:"id" yaml:"id"`
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"` RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"`
Effect DecisionEffect `json:"effect" yaml:"effect"` Effect DecisionEffect `json:"effect" yaml:"effect"`
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
@ -214,12 +225,35 @@ type DecisionEnvelope struct {
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"` Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,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"`
} }
// DecisionLifetimeKind identifies how an allow ends.
type DecisionLifetimeKind string
const (
DecisionLifetimeTTL DecisionLifetimeKind = "ttl"
)
// DefaultAllowTTL is the engine default when a policy package omits allow_ttl.
const DefaultAllowTTL = 15 * time.Minute
// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end.
const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated"
// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept,
// so the first honest shape is a policy-package-declared TTL.
type DecisionLifetime struct {
Kind DecisionLifetimeKind `json:"kind" yaml:"kind"`
TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
}
// DecisionBinding is the exact normalized authorization request evaluated by // DecisionBinding is the exact normalized authorization request evaluated by
// a decision. It lets a consumer verify structured action, target, actor, and // a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose. // context fields without parsing reason or diagnostic prose.
@ -232,11 +266,20 @@ type DecisionBinding struct {
RequestDigest string `json:"request_digest" yaml:"request_digest"` RequestDigest string `json:"request_digest" yaml:"request_digest"`
} }
// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request
// id, policy version, and caring_context are excluded: id is correlation, the
// version is provenance, and caring_context is an input-claim digest.
type requestDigestMaterial struct {
Tenant string `json:"tenant,omitempty"`
Subject SubjectRef `json:"subject"`
Action string `json:"action"`
Resource ResourceRef `json:"resource"`
Context map[string]any `json:"context,omitempty"`
}
// NewDecisionBinding returns a stable structured binding for the exact request // NewDecisionBinding returns a stable structured binding for the exact request
// an evaluator consumed. // an evaluator consumed.
func NewDecisionBinding(request CheckRequest) *DecisionBinding { func NewDecisionBinding(request CheckRequest) *DecisionBinding {
data, _ := json.Marshal(request)
sum := sha256.Sum256(data)
contextCopy := make(map[string]any, len(request.Context)) contextCopy := make(map[string]any, len(request.Context))
for key, value := range request.Context { for key, value := range request.Context {
contextCopy[key] = value contextCopy[key] = value
@ -247,7 +290,131 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding {
Action: request.Action, Action: request.Action,
Resource: request.Resource, Resource: request.Resource,
Context: contextCopy, Context: contextCopy,
RequestDigest: "sha256:" + hex.EncodeToString(sum[:]), RequestDigest: RequestDigest(request),
}
}
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
// JSON of tenant, subject, action, resource, and context.
func RequestDigest(request CheckRequest) string {
return CanonicalDigest(requestDigestMaterial{
Tenant: request.Tenant,
Subject: request.Subject,
Action: request.Action,
Resource: request.Resource,
Context: request.Context,
})
}
// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON.
// encoding/json sorts map keys, so two equal Go values agree.
func CanonicalDigest(value any) string {
data, err := json.Marshal(value)
if err != nil {
sum := sha256.Sum256(nil)
return "sha256:" + hex.EncodeToString(sum[:])
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// InputClaimDigests hashes the request-time claim classes the evaluator joined.
func InputClaimDigests(request CheckRequest) map[string]string {
digests := make(map[string]string)
if len(request.Context) > 0 {
digests["context"] = CanonicalDigest(request.Context)
}
if request.CaringContext != nil {
digests["caring_context"] = CanonicalDigest(request.CaringContext)
}
if len(digests) == 0 {
return nil
}
return digests
}
// DecisionCompletion carries evaluator-side inputs used to finish an envelope.
type DecisionCompletion struct {
AllowTTL string
Now time.Time
}
// CompleteDecision stamps contract version, input-claim digests, decision time,
// and an explicit allow lifetime. An allow with no stated end becomes a deny.
func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) {
if envelope == nil {
return
}
if envelope.ContractVersion == "" {
envelope.ContractVersion = DecisionRecordContractV1
}
if envelope.Provenance.InputClaimDigests == nil {
envelope.Provenance.InputClaimDigests = InputClaimDigests(request)
}
ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now)
}
// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow
// would have no stated end. Invalid strings return an error so package
// validation can reject them.
func ParseAllowTTL(declared string) (time.Duration, error) {
trimmed := strings.TrimSpace(declared)
if trimmed == "" {
return DefaultAllowTTL, nil
}
if strings.EqualFold(trimmed, "none") {
return 0, nil
}
ttl, err := time.ParseDuration(trimmed)
if err != nil {
return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err)
}
if ttl <= 0 {
return 0, nil
}
return ttl, nil
}
// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A
// missing or zero TTL denies the allow rather than mint a standing grant.
func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) {
if envelope == nil {
return
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if envelope.Provenance.DecisionTime == "" {
envelope.Provenance.DecisionTime = now.Format(time.RFC3339)
}
if envelope.Effect != DecisionEffectAllow {
return
}
ttl, err := ParseAllowTTL(declaredTTL)
if err != nil || ttl <= 0 {
if envelope.Diagnostics == nil {
envelope.Diagnostics = map[string]any{}
}
if envelope.Reason != "" {
envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason
}
envelope.Effect = DecisionEffectDeny
envelope.Reason = ReasonAllowLifetimeUnstated
envelope.MatchedRule = ReasonAllowLifetimeUnstated
envelope.Lifetime = nil
return
}
display := strings.TrimSpace(declaredTTL)
if display == "" {
display = "15m"
}
envelope.Lifetime = &DecisionLifetime{
Kind: DecisionLifetimeTTL,
TTL: display,
NotBefore: now.Format(time.RFC3339),
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
} }
} }
@ -306,12 +473,15 @@ type Obligation struct {
// DecisionProvenance captures evaluator and policy provenance. // DecisionProvenance captures evaluator and policy provenance.
type DecisionProvenance struct { type DecisionProvenance struct {
Evaluator string `json:"evaluator" yaml:"evaluator"` Evaluator string `json:"evaluator" yaml:"evaluator"`
Mode string `json:"mode" yaml:"mode"` Mode string `json:"mode" yaml:"mode"`
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
} }
// CaringDecisionMetadata carries CARING descriptor and conformance details in // CaringDecisionMetadata carries CARING descriptor and conformance details in

View file

@ -5,7 +5,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -71,6 +73,12 @@ func TestDecisionAndAuditExamplesParse(t *testing.T) {
if decision.Effect != api.DecisionEffectAllow { if decision.Effect != api.DecisionEffectAllow {
t.Errorf("Decision.Effect = %q; want allow", decision.Effect) t.Errorf("Decision.Effect = %q; want allow", decision.Effect)
} }
if decision.Lifetime == nil || decision.Lifetime.Kind != api.DecisionLifetimeTTL {
t.Fatalf("Decision.Lifetime = %+v; want ttl", decision.Lifetime)
}
if decision.ContractVersion != api.DecisionRecordContractV1 {
t.Errorf("Decision.ContractVersion = %q", decision.ContractVersion)
}
if decision.Caring == nil || decision.Caring.Profile != api.CaringProfileCaring040RC2 { if decision.Caring == nil || decision.Caring.Profile != api.CaringProfileCaring040RC2 {
t.Fatalf("Decision.Caring = %+v; want CARING profile metadata", decision.Caring) t.Fatalf("Decision.Caring = %+v; want CARING profile metadata", decision.Caring)
} }
@ -112,6 +120,80 @@ func TestActionAuthorizationExampleParses(t *testing.T) {
} }
} }
func TestRequestDigestIgnoresIDAndChangesWithAction(t *testing.T) {
request := api.CheckRequest{
ID: "check:one",
Tenant: "tenant:alpha",
Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman},
Action: "read",
Resource: api.ResourceRef{
ID: "document:internal-note",
Type: "document",
System: "markitect-tool",
},
Context: map[string]any{"purpose": "project-delivery"},
}
first := api.RequestDigest(request)
if !strings.HasPrefix(first, "sha256:") || len(first) != len("sha256:")+64 {
t.Fatalf("RequestDigest = %q", first)
}
same := request
same.ID = "check:other"
same.PolicyVersion = "v9"
if api.RequestDigest(same) != first {
t.Fatal("digest changed when only id/policy_version changed")
}
changed := request
changed.Action = "destroy"
if api.RequestDigest(changed) == first {
t.Fatal("digest did not change when action changed")
}
binding := api.NewDecisionBinding(request)
if binding.RequestDigest != first {
t.Fatalf("binding digest %q != RequestDigest %q", binding.RequestDigest, first)
}
}
func TestApplyAllowLifetimeDefaultDeclaredAndNone(t *testing.T) {
now := mustParseTime(t, "2026-08-29T12:00:00Z")
allow := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&allow, "", now)
if allow.Effect != api.DecisionEffectAllow || allow.Lifetime == nil {
t.Fatalf("default TTL denied or skipped: %+v", allow)
}
if allow.Lifetime.TTL != "15m" || allow.Lifetime.ExpiresAt != "2026-08-29T12:15:00Z" {
t.Fatalf("default lifetime = %+v", allow.Lifetime)
}
declared := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&declared, "5m", now)
if declared.Lifetime == nil || declared.Lifetime.TTL != "5m" || declared.Lifetime.ExpiresAt != "2026-08-29T12:05:00Z" {
t.Fatalf("declared lifetime = %+v", declared.Lifetime)
}
unstated := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
api.ApplyAllowLifetime(&unstated, "none", now)
if unstated.Effect != api.DecisionEffectDeny || unstated.Reason != api.ReasonAllowLifetimeUnstated {
t.Fatalf("unstated allow = %+v; want deny", unstated)
}
if unstated.Lifetime != nil {
t.Fatalf("unstated allow still has lifetime %+v", unstated.Lifetime)
}
}
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time %q: %v", value, err)
}
return parsed
}
func TestSchemaFilesAreJSON(t *testing.T) { func TestSchemaFilesAreJSON(t *testing.T) {
schemaDir := filepath.Join("..", "..", "schemas") schemaDir := filepath.Join("..", "..", "schemas")
entries, err := os.ReadDir(schemaDir) entries, err := os.ReadDir(schemaDir)

View file

@ -11,7 +11,7 @@ JSON Schema definitions for flex-auth's canonical artefacts:
- `policy_package.schema.json` - `policy_package.schema.json`
- `policy_fixture.schema.json` - `policy_fixture.schema.json`
- `check_request.schema.json` - `check_request.schema.json`
- `decision_envelope.schema.json` - `decision_envelope.schema.json` — published decision-record contract (`flex-auth.decision-record.v1`; see `docs/decision-record-contract.md`)
- `action_authorization.schema.json` - `action_authorization.schema.json`
- `audit_event.schema.json` - `audit_event.schema.json`

View file

@ -2,11 +2,13 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/decision_envelope.schema.json", "$id": "https://flex-auth.netkingdom/schemas/decision_envelope.schema.json",
"title": "DecisionEnvelope", "title": "DecisionEnvelope",
"description": "Published flex-auth decision-record contract (flex-auth.decision-record.v1). This is the PDP's output artifact under security-layer-model_v0.7 §17.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "effect", "resource", "subject", "provenance"], "required": ["id", "effect", "resource", "subject", "provenance"],
"properties": { "properties": {
"id": {"type": "string", "minLength": 1}, "id": {"type": "string", "minLength": 1},
"contract_version": {"const": "flex-auth.decision-record.v1"},
"request_id": {"type": "string", "minLength": 1}, "request_id": {"type": "string", "minLength": 1},
"effect": {"enum": ["allow", "deny", "redact", "audit_only", "not_applicable"]}, "effect": {"enum": ["allow", "deny", "redact", "audit_only", "not_applicable"]},
"reason": {"type": "string"}, "reason": {"type": "string"},
@ -15,11 +17,18 @@
"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"}, "binding": {"$ref": "#/$defs/decision_binding"},
"lifetime": {"$ref": "#/$defs/lifetime"},
"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"}
}, },
"allOf": [
{
"if": {"properties": {"effect": {"const": "allow"}}, "required": ["effect"]},
"then": {"required": ["lifetime"]}
}
],
"$defs": { "$defs": {
"decision_binding": { "decision_binding": {
"type": "object", "type": "object",
@ -43,6 +52,17 @@
"parameters": {"type": "object", "additionalProperties": true} "parameters": {"type": "object", "additionalProperties": true}
} }
}, },
"lifetime": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "expires_at"],
"properties": {
"kind": {"enum": ["ttl"]},
"ttl": {"type": "string", "minLength": 1},
"not_before": {"type": "string", "minLength": 1},
"expires_at": {"type": "string", "minLength": 1}
}
},
"provenance": { "provenance": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -52,7 +72,13 @@
"mode": {"type": "string", "minLength": 1}, "mode": {"type": "string", "minLength": 1},
"policy_package": {"type": "string", "minLength": 1}, "policy_package": {"type": "string", "minLength": 1},
"policy_version": {"type": "string", "minLength": 1}, "policy_version": {"type": "string", "minLength": 1},
"policy_package_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"registry_snapshot_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"directory_etag": {"type": "string", "minLength": 1}, "directory_etag": {"type": "string", "minLength": 1},
"input_claim_digests": {
"type": "object",
"additionalProperties": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}
},
"decision_time": {"type": "string", "minLength": 1} "decision_time": {"type": "string", "minLength": 1}
} }
}, },

View file

@ -25,7 +25,12 @@
}, },
"caring": {"$ref": "#/$defs/caring_policy_metadata"}, "caring": {"$ref": "#/$defs/caring_policy_metadata"},
"activation": {"type": "object", "additionalProperties": true}, "activation": {"type": "object", "additionalProperties": true},
"metadata": {"type": "object", "additionalProperties": true} "metadata": {"type": "object", "additionalProperties": true},
"allow_ttl": {
"type": "string",
"minLength": 1,
"description": "Go duration bounding every allow this package produces (e.g. 15m). Omit to use the engine default of 15m. The values none and 0s mean no stated end; the engine denies those allows."
}
}, },
"$defs": { "$defs": {
"caring_policy_metadata": { "caring_policy_metadata": {

View file

@ -0,0 +1,28 @@
// Command check_layer_conformance asserts the INTENT.md layer declaration
// and that no Tooling client exists in production Go sources.
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/netkingdom/flex-auth/internal/layer"
)
func main() {
root, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := os.Stat(filepath.Join(root, "INTENT.md")); err != nil {
fmt.Fprintf(os.Stderr, "INTENT.md not found in %s\n", root)
os.Exit(2)
}
if err := layer.Check(root); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("PASS — Engine/PDP declaration parses; no Tooling client in the tree.")
}

View file

@ -4,13 +4,13 @@ type: workplan
title: "Layer model v0.7 conformance: provenance, lifetimes, deadlines, and the decision contract" title: "Layer model v0.7 conformance: provenance, lifetimes, deadlines, and the decision contract"
domain: infotech domain: infotech
repo: flex-auth repo: flex-auth
status: ready status: finished
owner: codex owner: grok
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: P1 planning_priority: P1
planning_order: 119 planning_order: 119
created: "2026-08-29" created: "2026-08-29"
updated: "2026-08-29" updated: "2026-09-03"
reviewed_at: "2026-08-29" reviewed_at: "2026-08-29"
reviewed_against_commit: "d402408" reviewed_against_commit: "d402408"
reviewed_note: >- reviewed_note: >-
@ -54,7 +54,7 @@ would publish a number nobody can check.
```task ```task
id: FLEX-WP-0019-T01 id: FLEX-WP-0019-T01
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "f72b1305-114e-5ba2-b84c-f6cc0b524178" state_hub_task_id: "f72b1305-114e-5ba2-b84c-f6cc0b524178"
``` ```
@ -72,7 +72,7 @@ Closes G1's remaining half. Wire it into `make test`.
```task ```task
id: FLEX-WP-0019-T02 id: FLEX-WP-0019-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "7a980074-8488-5ab1-9202-60878adb261d" state_hub_task_id: "7a980074-8488-5ab1-9202-60878adb261d"
``` ```
@ -94,7 +94,7 @@ snapshot changes the digest and that two decisions over the same snapshot agree.
```task ```task
id: FLEX-WP-0019-T03 id: FLEX-WP-0019-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "9d9c0e7a-56e2-5c71-9110-cea973243c22" state_hub_task_id: "9d9c0e7a-56e2-5c71-9110-cea973243c22"
``` ```
@ -115,7 +115,7 @@ appear in the decision record and the published schema.
```task ```task
id: FLEX-WP-0019-T04 id: FLEX-WP-0019-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "d5917b24-2efb-503e-9a9d-837702cafc1c" state_hub_task_id: "d5917b24-2efb-503e-9a9d-837702cafc1c"
``` ```
@ -134,7 +134,7 @@ identify the snapshot a decision read. Publish as
```task ```task
id: FLEX-WP-0019-T05 id: FLEX-WP-0019-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "f7f501d7-6862-5542-88f7-78b704524706" state_hub_task_id: "f7f501d7-6862-5542-88f7-78b704524706"
``` ```
@ -154,7 +154,7 @@ flex-auth's.
```task ```task
id: FLEX-WP-0019-T06 id: FLEX-WP-0019-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "bc109ee3-14b0-5603-a655-0d7376c2a41c" state_hub_task_id: "bc109ee3-14b0-5603-a655-0d7376c2a41c"
``` ```
@ -168,3 +168,16 @@ Publish what it is computed over, what normalization applies, its stability
guarantees across versions, and worked examples of a permitted retry and a guarantees across versions, and worked examples of a permitted retry and a
refused replay. Consumers cannot honour §6.4.2 against an undocumented digest, refused replay. Consumers cannot honour §6.4.2 against an undocumented digest,
and negative caching under §6.4.2 needs the same guidance. and negative caching under §6.4.2 needs the same guidance.
## Closeout
Finished 2026-09-03. `go test -race ./...` and `go run ./tools/check_layer_conformance.go` pass.
| Task | Landed as |
| --- | --- |
| T01 | `internal/layer`, `tools/check_layer_conformance.go`, wired into `make test` |
| T02 | `registry.Store.Digest` in `DecisionProvenance.registry_snapshot_digest` on standalone and delegated envelopes |
| T03 | Policy `allow_ttl`, engine default `15m`, deny `allow_lifetime_unstated` |
| T04 | `docs/decision-input-freshness.md`, referenced from `INTENT.md` |
| T05 | `flex-auth.decision-record.v1` in schema + `docs/decision-record-contract.md` |
| T06 | `docs/canonical-request-digest.md`; digest is tenant/subject/action/resource/context |