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
flex-auth's conformance state under §11 is **conforming with one declared gap**.
It holds no Tooling client, so the §5 shapes do not apply to it, and it is not
PEP-shaped, so it owes no stance map under §6.4.
flex-auth's conformance state under §11 is **conforming**. The mechanical
check is `go run ./tools/check_layer_conformance.go` (wired into `make test`):
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).**
`DecisionProvenance` carries the evaluator, mode, policy package, policy version,
directory ETag, and decision time, but no digest of the registry snapshot that
supplied resource, subject, and relationship facts. A decision that turned on
registry content cannot be replayed from its own provenance.
The former declared gap — registry-snapshot digest in decision provenance —
is closed. `DecisionProvenance` carries `registry_snapshot_digest` so a
decision that turned on registry content is replayable from its own record.
Revocation visibility deadlines per input class are stated in
[`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**,
on flex-auth's own argument: a stated visibility deadline for a fact carried by
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
Outcome-determining content still 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
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

View file

@ -4,7 +4,7 @@ PKG := ./...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
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
@ -12,9 +12,12 @@ build:
@mkdir -p $(BIN_DIR)
go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/flex-auth
test:
test: check-layer
go test -race $(PKG)
check-layer:
go run ./tools/check_layer_conformance.go
vet:
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-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-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 |
| 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 |
@ -109,12 +109,12 @@
| 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-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-T02 | todo | — | 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-T04 | todo | — | 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-T06 | 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 | done | — | 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 | done | — | 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 | 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-T02 | 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,
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.
How that digest is computed, what is excluded, and when replay is permitted
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
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": {
"id": "decision:contract-example",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-destroy-example",
"effect": "allow",
"reason": "destruction_approved",
@ -87,7 +88,13 @@
"context": {
"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": {
"evaluator": "flex-auth/local",

View file

@ -1,5 +1,6 @@
{
"id": "decision:tenant-alpha-internal-note",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:tenant-alpha-internal-note",
"effect": "allow",
"reason": "reader_relation",
@ -33,7 +34,13 @@
"context": {
"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": [
{
@ -51,6 +58,11 @@
"mode": "standalone",
"policy_package": "markitect.documents",
"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"
},
"caring": {

View file

@ -13,11 +13,14 @@ import (
// Adapter maps flex-auth checks and resources to Keycloak Authorization
// Services while preserving the flex-auth decision envelope.
type Adapter struct {
client Client
realm string
audience string
policyPackage string
policyVersion string
client Client
realm string
audience string
policyPackage string
policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
}
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 &Adapter{
client: client,
realm: options.Realm,
audience: options.Audience,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
client: client,
realm: options.Realm,
audience: options.Audience,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, nil
}
@ -154,7 +160,8 @@ func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest,
},
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
}
@ -179,10 +186,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationR
},
Caring: caringDecisionMetadata(request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}),
}
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
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 {
profile := api.CaringProfileCaring040RC2
if descriptor != nil && descriptor.Profile != "" {

View file

@ -23,10 +23,13 @@ type Client interface {
// Options configures Keycloak mapping without making Keycloak the source of
// truth for flex-auth resources or policies.
type Options struct {
Realm string
Audience string
PolicyPackage string
PolicyVersion string
Realm string
Audience string
PolicyPackage string
PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
}
// 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.
type Adapter struct {
backend Backend
backendName string
policyPackage string
policyVersion string
backend Backend
backendName string
policyPackage string
policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
}
// New creates a relationship PDP adapter.
@ -30,10 +33,13 @@ func New(backend Backend, options Options) (*Adapter, error) {
backendName = "relationship"
}
return &Adapter{
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, nil
}
@ -230,7 +236,8 @@ func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequ
},
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
}
@ -255,10 +262,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCh
},
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, "")
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 {
if request.CaringContext != nil {
return request.CaringContext

View file

@ -27,9 +27,12 @@ type Backend interface {
// Options configures the relationship adapter without binding callers to a
// specific backend protocol.
type Options struct {
BackendName string
PolicyPackage string
PolicyVersion string
BackendName string
PolicyPackage string
PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
}
// 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.
type Adapter struct {
backend Backend
backendName string
policyPackage string
policyVersion string
language Language
caring api.CaringPolicyMetadata
backend Backend
backendName string
policyPackage string
policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
language Language
caring api.CaringPolicyMetadata
}
// New creates a delegated rule-PDP adapter.
@ -36,12 +39,15 @@ func New(backend Backend, options Options) (*Adapter, error) {
language = LanguageRego
}
return &Adapter{
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
language: language,
caring: options.Caring,
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
language: language,
caring: options.Caring,
}, 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),
}
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
}
@ -259,10 +266,22 @@ func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation Evaluatio
},
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)
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(
request api.CheckRequest,
descriptor *api.CaringAccessDescriptor,

View file

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

View file

@ -16,9 +16,12 @@ import (
// Adapter delegates checks, directory writes, and policy bundle publication to
// Topaz while preserving flex-auth request and decision contracts.
type Adapter struct {
client Client
policyPackage string
policyVersion string
client Client
policyPackage string
policyVersion string
policyPackageDigest string
registrySnapshotDigest string
allowTTL string
}
// 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 &Adapter{
client: client,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
client: client,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
policyPackageDigest: options.PolicyPackageDigest,
registrySnapshotDigest: options.RegistrySnapshotDigest,
allowTTL: options.AllowTTL,
}, 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),
}
envelope = a.finish(request, envelope)
envelope.ID = decisionID(policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, result.DirectoryETag)
if envelope.Caring != nil && envelope.Caring.ExposureEvent != nil && envelope.Caring.ExposureEvent.ID == "" {
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),
}
envelope = a.finish(request, envelope)
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "")
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) {
diagnostics["adapter"] = "topaz"
diagnostics["mode"] = DelegatedMode

View file

@ -86,6 +86,12 @@ func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) {
if got.Provenance.DirectoryETag != "etag:rel-42" {
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" {
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()
adapter, err := topaz.New(client, topaz.Options{
PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1",
PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1",
RegistrySnapshotDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
})
if err != nil {
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
// public flex-auth API.
type Options struct {
PolicyPackage string
PolicyVersion string
PolicyPackage string
PolicyVersion string
PolicyPackageDigest string
RegistrySnapshotDigest string
AllowTTL string
}
// DirectoryObject is the Topaz directory object shape used by the REST

View file

@ -10,6 +10,7 @@ import (
"slices"
"strings"
"sync"
"time"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
@ -24,6 +25,7 @@ type Engine struct {
mu sync.RWMutex
history map[string]api.DecisionEnvelope
log DecisionRecorder
clock func() time.Time
}
// DecisionRecorder persists decision envelopes.
@ -82,6 +84,23 @@ func (e *Engine) SetDecisionLog(log DecisionRecorder) {
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.
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
normalized, facts := e.normalizeRequest(request)
@ -307,13 +326,19 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
"matched_relationship": facts.matchedRelationship,
},
Provenance: api.DecisionProvenance{
Evaluator: "flex-auth/local",
Mode: "standalone",
PolicyPackage: e.policy.Metadata.ID,
PolicyVersion: e.policy.Metadata.Version,
Evaluator: "flex-auth/local",
Mode: "standalone",
PolicyPackage: e.policy.Metadata.ID,
PolicyVersion: e.policy.Metadata.Version,
PolicyPackageDigest: e.policy.Digest(),
RegistrySnapshotDigest: e.store.Digest(),
},
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)
return envelope
}

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"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) {
engine := newTestEngine(t)
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 {
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"))
if err != nil {
t.Fatalf("LoadAndValidateFile policy: %v", err)
}
policyPackage.Metadata.AllowTTL = allowTTL
engine, err := decision.NewEngine(store, policyPackage)
if err != nil {
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"
"sort"
"strings"
"time"
"github.com/open-policy-agent/opa/ast"
"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)
}
// 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.
func (p *Package) Validate(ctx context.Context) ValidationResult {
result := ValidationResult{}
@ -235,6 +247,24 @@ func (p *Package) metadataDiagnostics() []Diagnostic {
if p.Metadata.Package == "" {
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
}

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.
func (s *Store) PutProtectedSystem(system api.ProtectedSystemManifest) error {
if system.ID == "" {

View file

@ -3,6 +3,7 @@ package registry_test
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"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) {
store := registry.NewStore()
if err := store.PutSubject(api.Subject{}); err == nil {

View file

@ -4,6 +4,9 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// ProtectedSystemManifest describes a system that delegates authorization to
@ -118,6 +121,10 @@ type PolicyPackageMetadata struct {
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
Activation map[string]any `json:"activation,omitempty" yaml:"activation,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.
@ -202,10 +209,14 @@ const (
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
// evaluators.
// evaluators. It is flex-auth's published decision-record contract (§17).
type DecisionEnvelope struct {
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"`
Effect DecisionEffect `json:"effect" yaml:"effect"`
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
@ -214,12 +225,35 @@ type DecisionEnvelope struct {
Resource ResourceRef `json:"resource" yaml:"resource"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"`
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
}
// 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
// a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose.
@ -232,11 +266,20 @@ type DecisionBinding struct {
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
// 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
@ -247,7 +290,131 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding {
Action: request.Action,
Resource: request.Resource,
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.
type DecisionProvenance struct {
Evaluator string `json:"evaluator" yaml:"evaluator"`
Mode string `json:"mode" yaml:"mode"`
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
Evaluator string `json:"evaluator" yaml:"evaluator"`
Mode string `json:"mode" yaml:"mode"`
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,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

View file

@ -5,7 +5,9 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
@ -71,6 +73,12 @@ func TestDecisionAndAuditExamplesParse(t *testing.T) {
if decision.Effect != api.DecisionEffectAllow {
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 {
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) {
schemaDir := filepath.Join("..", "..", "schemas")
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_fixture.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`
- `audit_event.schema.json`

View file

@ -2,11 +2,13 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/decision_envelope.schema.json",
"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",
"additionalProperties": false,
"required": ["id", "effect", "resource", "subject", "provenance"],
"properties": {
"id": {"type": "string", "minLength": 1},
"contract_version": {"const": "flex-auth.decision-record.v1"},
"request_id": {"type": "string", "minLength": 1},
"effect": {"enum": ["allow", "deny", "redact", "audit_only", "not_applicable"]},
"reason": {"type": "string"},
@ -15,11 +17,18 @@
"resource": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/resource_ref"},
"subject": {"$ref": "https://flex-auth.netkingdom/schemas/check_request.schema.json#/$defs/subject_ref"},
"binding": {"$ref": "#/$defs/decision_binding"},
"lifetime": {"$ref": "#/$defs/lifetime"},
"obligations": {"type": "array", "items": {"$ref": "#/$defs/obligation"}},
"diagnostics": {"type": "object", "additionalProperties": true},
"provenance": {"$ref": "#/$defs/provenance"},
"caring": {"$ref": "#/$defs/caring_decision_metadata"}
},
"allOf": [
{
"if": {"properties": {"effect": {"const": "allow"}}, "required": ["effect"]},
"then": {"required": ["lifetime"]}
}
],
"$defs": {
"decision_binding": {
"type": "object",
@ -43,6 +52,17 @@
"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": {
"type": "object",
"additionalProperties": false,
@ -52,7 +72,13 @@
"mode": {"type": "string", "minLength": 1},
"policy_package": {"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},
"input_claim_digests": {
"type": "object",
"additionalProperties": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}
},
"decision_time": {"type": "string", "minLength": 1}
}
},

View file

@ -25,7 +25,12 @@
},
"caring": {"$ref": "#/$defs/caring_policy_metadata"},
"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": {
"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"
domain: infotech
repo: flex-auth
status: ready
owner: codex
status: finished
owner: grok
topic_slug: netkingdom
planning_priority: P1
planning_order: 119
created: "2026-08-29"
updated: "2026-08-29"
updated: "2026-09-03"
reviewed_at: "2026-08-29"
reviewed_against_commit: "d402408"
reviewed_note: >-
@ -54,7 +54,7 @@ would publish a number nobody can check.
```task
id: FLEX-WP-0019-T01
status: todo
status: done
priority: medium
state_hub_task_id: "f72b1305-114e-5ba2-b84c-f6cc0b524178"
```
@ -72,7 +72,7 @@ Closes G1's remaining half. Wire it into `make test`.
```task
id: FLEX-WP-0019-T02
status: todo
status: done
priority: high
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
id: FLEX-WP-0019-T03
status: todo
status: done
priority: high
state_hub_task_id: "9d9c0e7a-56e2-5c71-9110-cea973243c22"
```
@ -115,7 +115,7 @@ appear in the decision record and the published schema.
```task
id: FLEX-WP-0019-T04
status: todo
status: done
priority: high
state_hub_task_id: "d5917b24-2efb-503e-9a9d-837702cafc1c"
```
@ -134,7 +134,7 @@ identify the snapshot a decision read. Publish as
```task
id: FLEX-WP-0019-T05
status: todo
status: done
priority: medium
state_hub_task_id: "f7f501d7-6862-5542-88f7-78b704524706"
```
@ -154,7 +154,7 @@ flex-auth's.
```task
id: FLEX-WP-0019-T06
status: todo
status: done
priority: medium
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
refused replay. Consumers cannot honour §6.4.2 against an undocumented digest,
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 |