diff --git a/INTENT.md b/INTENT.md index 6ad79e0..ff26ce2 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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 diff --git a/Makefile b/Makefile index 3b85ced..9cc60b4 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c537d46..5bcff75 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/action-bound-authorization-contract.md b/docs/action-bound-authorization-contract.md index 1945569..67b6800 100644 --- a/docs/action-bound-authorization-contract.md +++ b/docs/action-bound-authorization-contract.md @@ -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 diff --git a/docs/canonical-request-digest.md b/docs/canonical-request-digest.md new file mode 100644 index 0000000..eef035a --- /dev/null +++ b/docs/canonical-request-digest.md @@ -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. diff --git a/docs/decision-input-freshness.md b/docs/decision-input-freshness.md new file mode 100644 index 0000000..2e4d3a8 --- /dev/null +++ b/docs/decision-input-freshness.md @@ -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. diff --git a/docs/decision-record-contract.md b/docs/decision-record-contract.md new file mode 100644 index 0000000..ce6b3d0 --- /dev/null +++ b/docs/decision-record-contract.md @@ -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. diff --git a/examples/caring/action_authorization.json b/examples/caring/action_authorization.json index 2abe187..50835f0 100644 --- a/examples/caring/action_authorization.json +++ b/examples/caring/action_authorization.json @@ -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", diff --git a/examples/caring/decision_envelope.json b/examples/caring/decision_envelope.json index 78b600f..e40ec4f 100644 --- a/examples/caring/decision_envelope.json +++ b/examples/caring/decision_envelope.json @@ -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": { diff --git a/internal/adapters/keycloak/adapter.go b/internal/adapters/keycloak/adapter.go index 89d9871..5b517f7 100644 --- a/internal/adapters/keycloak/adapter.go +++ b/internal/adapters/keycloak/adapter.go @@ -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 != "" { diff --git a/internal/adapters/keycloak/types.go b/internal/adapters/keycloak/types.go index 59e4ddf..1172901 100644 --- a/internal/adapters/keycloak/types.go +++ b/internal/adapters/keycloak/types.go @@ -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 diff --git a/internal/adapters/relationship/adapter.go b/internal/adapters/relationship/adapter.go index e6b3ab5..67061aa 100644 --- a/internal/adapters/relationship/adapter.go +++ b/internal/adapters/relationship/adapter.go @@ -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 diff --git a/internal/adapters/relationship/types.go b/internal/adapters/relationship/types.go index dc0b184..94bb075 100644 --- a/internal/adapters/relationship/types.go +++ b/internal/adapters/relationship/types.go @@ -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. diff --git a/internal/adapters/rule/adapter.go b/internal/adapters/rule/adapter.go index 8649ed9..64216ee 100644 --- a/internal/adapters/rule/adapter.go +++ b/internal/adapters/rule/adapter.go @@ -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, diff --git a/internal/adapters/rule/types.go b/internal/adapters/rule/types.go index 922073a..3cf6630 100644 --- a/internal/adapters/rule/types.go +++ b/internal/adapters/rule/types.go @@ -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. diff --git a/internal/adapters/topaz/adapter.go b/internal/adapters/topaz/adapter.go index 2f1708a..d336dae 100644 --- a/internal/adapters/topaz/adapter.go +++ b/internal/adapters/topaz/adapter.go @@ -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 diff --git a/internal/adapters/topaz/adapter_test.go b/internal/adapters/topaz/adapter_test.go index 5b7d154..29a6643 100644 --- a/internal/adapters/topaz/adapter_test.go +++ b/internal/adapters/topaz/adapter_test.go @@ -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) diff --git a/internal/adapters/topaz/types.go b/internal/adapters/topaz/types.go index aa8b87b..f490573 100644 --- a/internal/adapters/topaz/types.go +++ b/internal/adapters/topaz/types.go @@ -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 diff --git a/internal/decision/engine.go b/internal/decision/engine.go index ba7e6cf..33d8dc6 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -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 } diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index 804d446..fd1c8cf 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -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) diff --git a/internal/layer/conformance.go b/internal/layer/conformance.go new file mode 100644 index 0000000..f65cabd --- /dev/null +++ b/internal/layer/conformance.go @@ -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") +} diff --git a/internal/layer/conformance_test.go b/internal/layer/conformance_test.go new file mode 100644 index 0000000..5f4dba0 --- /dev/null +++ b/internal/layer/conformance_test.go @@ -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), "..", "..")) +} diff --git a/internal/policy/package.go b/internal/policy/package.go index bf28492..644d5aa 100644 --- a/internal/policy/package.go +++ b/internal/policy/package.go @@ -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 } diff --git a/internal/registry/store.go b/internal/registry/store.go index 979ff50..ad873aa 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -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 == "" { diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go index 2de1cbc..60e6918 100644 --- a/internal/registry/store_test.go +++ b/internal/registry/store_test.go @@ -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 { diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index 9163e95..f450c39 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -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 diff --git a/pkg/api/canonical_test.go b/pkg/api/canonical_test.go index 309db7e..640794f 100644 --- a/pkg/api/canonical_test.go +++ b/pkg/api/canonical_test.go @@ -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) diff --git a/schemas/README.md b/schemas/README.md index f01862b..a3f23ab 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -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` diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index b78466a..b653ac2 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -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} } }, diff --git a/schemas/policy_package.schema.json b/schemas/policy_package.schema.json index f7c2600..f52f3cf 100644 --- a/schemas/policy_package.schema.json +++ b/schemas/policy_package.schema.json @@ -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": { diff --git a/tools/check_layer_conformance.go b/tools/check_layer_conformance.go new file mode 100644 index 0000000..bf492ef --- /dev/null +++ b/tools/check_layer_conformance.go @@ -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.") +} diff --git a/workplans/FLEX-WP-0019-layer-model-conformance.md b/workplans/FLEX-WP-0019-layer-model-conformance.md index 6c0b85e..27d8914 100644 --- a/workplans/FLEX-WP-0019-layer-model-conformance.md +++ b/workplans/FLEX-WP-0019-layer-model-conformance.md @@ -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 |