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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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