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
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue