Finish FLEX-WP-0019 layer-model v0.7 conformance
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:
parent
9689894c15
commit
56940727bf
32 changed files with 1194 additions and 111 deletions
36
INTENT.md
36
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
|
||||
|
||||
|
|
|
|||
7
Makefile
7
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
98
docs/canonical-request-digest.md
Normal file
98
docs/canonical-request-digest.md
Normal 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.
|
||||
72
docs/decision-input-freshness.md
Normal file
72
docs/decision-input-freshness.md
Normal 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.
|
||||
57
docs/decision-record-contract.md
Normal file
57
docs/decision-record-contract.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
147
internal/layer/conformance.go
Normal file
147
internal/layer/conformance.go
Normal 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")
|
||||
}
|
||||
56
internal/layer/conformance_test.go
Normal file
56
internal/layer/conformance_test.go
Normal 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), "..", ".."))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
28
tools/check_layer_conformance.go
Normal file
28
tools/check_layer_conformance.go
Normal 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.")
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue