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