Add rule PDP adapter boundary
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 07:13:27 +02:00
parent 4bb329c921
commit ad4895187b
7 changed files with 1011 additions and 1 deletions

View File

@@ -0,0 +1,103 @@
# Rule PDP Adapter Boundary
Status: implemented for FLEX-WP-0004 P4.3.
## Role
The rule PDP adapter is the common boundary for OPA/Rego, Cedar-style
policy services, and other engines that evaluate a policy language over
a structured request. It is separate from the relationship-PDP boundary:
relationship backends answer tuple reachability questions, while rule
backends evaluate policy logic over subject, action, resource, context,
and CARING metadata.
## Canonical Input
All rule backends receive the same canonical input shape:
```text
input.subject
input.action
input.resource
input.context
input.caring_context
input.policy.package
input.policy.version
```
OPA/Rego can consume this shape directly. Cedar adapters translate the
same fields into principal/action/resource/context entities at the
backend boundary. Protected systems do not see backend-native input
syntax.
## Policy Artifacts
`PolicyArtifactFromPackage` converts a validated Rego-in-Markdown
package into a delegated artifact:
- `language=rego`
- package id and version from frontmatter
- extracted Rego module unchanged
- test blocks and fixtures preserved
- CARING policy metadata preserved
Cedar and other rule engines use the same `PolicyArtifact` envelope,
but may reject unsupported artifacts with `rule_policy_unsupported`.
## Fixtures
`EvaluateFixtures` runs `api.PolicyFixture` values through the delegated
adapter and compares the returned effect, reason, and obligations. This
keeps delegated backends honest against the same fixtures used by the
standalone evaluator.
## Obligations And Diagnostics
Rule backends can return obligations such as masking, audit, or approval
requirements. The adapter copies them into the canonical
`DecisionEnvelope`. Backend diagnostics are preserved and supplemented
with:
- `adapter=rule`
- backend name
- delegated mode
- language
- policy package and version
- fail-closed reason when present
## Versioning
The envelope records backend policy version in
`matched_policy_version` and `provenance.policy_version`. A backend may
return a newer concrete revision than the request asked for; the adapter
records what actually matched.
## Failure Behavior
The adapter fails closed for:
- backend unavailable: `rule_backend_unavailable`
- stale policy: `rule_policy_stale`
- partial result: `rule_partial_result`
- invalid input: `rule_request_incomplete`
- unsupported policy artifact: `rule_policy_unsupported`
Each failure returns a deny envelope with `diagnostics.rule_failure` and
a CARING conformance finding.
## CARING Preservation
Rule engines vary in how much of CARING they can represent natively.
flex-auth keeps CARING outside the backend-specific language contract:
- request descriptor wins;
- backend result descriptor is next;
- policy frontmatter supplies profile and expected dimensions;
- gaps become `RULE-CARING-METADATA-GAP` or
`RULE-CARING-DESCRIPTOR-MISSING` findings.
The decision envelope preserves descriptor, scope, planes,
capabilities, exposure modes, restrictions, derived capabilities,
conformance findings, exposure-event hooks, obligations, and diagnostics.
Backend-native policy names should never replace canonical CARING roles
in protected-system responses.

View File

@@ -0,0 +1,461 @@
package rule
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
// 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
}
// New creates a delegated rule-PDP adapter.
func New(backend Backend, options Options) (*Adapter, error) {
if backend == nil {
return nil, fmt.Errorf("rule backend is required")
}
backendName := options.BackendName
if backendName == "" {
backendName = "rule"
}
language := options.Language
if language == "" {
language = LanguageRego
}
return &Adapter{
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
language: language,
caring: options.Caring,
}, nil
}
// Check evaluates one request through the delegated rule backend.
func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
evaluation, err := a.buildEvaluationRequest(request)
if err != nil {
return a.failureEnvelope(request, EvaluationRequest{}, FailureInvalidRequest, err), nil
}
result, err := a.backend.Evaluate(ctx, evaluation)
if err != nil {
return a.failureEnvelope(request, evaluation, failureKind(err), err), nil
}
if result.StalePolicy {
return a.failureEnvelope(request, evaluation, FailureStalePolicy, nil), nil
}
if result.PartialResult {
return a.failureEnvelope(request, evaluation, FailurePartialResult, nil), nil
}
return a.envelope(request, evaluation, result), nil
}
// BatchCheck evaluates resources in request order.
func (a *Adapter) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
evaluations := make([]EvaluationRequest, 0, len(request.Resources))
checks := make([]api.CheckRequest, 0, len(request.Resources))
for _, resource := range request.Resources {
check := api.CheckRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: resource,
Context: request.Context,
PolicyVersion: request.PolicyVersion,
}
evaluation, err := a.buildEvaluationRequest(check)
if err != nil {
return []api.DecisionEnvelope{a.failureEnvelope(check, EvaluationRequest{}, FailureInvalidRequest, err)}, nil
}
evaluations = append(evaluations, evaluation)
checks = append(checks, check)
}
results, err := a.backend.BatchEvaluate(ctx, evaluations)
if err != nil {
decisions := make([]api.DecisionEnvelope, 0, len(checks))
for i, check := range checks {
decisions = append(decisions, a.failureEnvelope(check, evaluations[i], failureKind(err), err))
}
return decisions, nil
}
if len(results) != len(checks) {
decisions := make([]api.DecisionEnvelope, 0, len(checks))
for i, check := range checks {
decisions = append(decisions, a.failureEnvelope(check, evaluations[i], FailurePartialResult, nil))
}
return decisions, nil
}
decisions := make([]api.DecisionEnvelope, 0, len(results))
for i, result := range results {
if result.StalePolicy {
decisions = append(decisions, a.failureEnvelope(checks[i], evaluations[i], FailureStalePolicy, nil))
continue
}
if result.PartialResult {
decisions = append(decisions, a.failureEnvelope(checks[i], evaluations[i], FailurePartialResult, nil))
continue
}
decisions = append(decisions, a.envelope(checks[i], evaluations[i], result))
}
return decisions, nil
}
// ImportPolicy imports a flex-auth Rego-in-Markdown package as a delegated
// rule artifact.
func (a *Adapter) ImportPolicy(ctx context.Context, pkg *policy.Package) (PolicyImportReport, error) {
if pkg == nil {
return PolicyImportReport{}, fmt.Errorf("policy package is required")
}
artifact := PolicyArtifactFromPackage(pkg)
report, err := a.backend.ImportPolicy(ctx, artifact)
if err != nil {
return PolicyImportReport{}, err
}
a.policyPackage = artifact.ID
a.policyVersion = artifact.Version
a.language = artifact.Language
a.caring = artifact.Caring
return report, nil
}
// EvaluateFixtures runs policy fixtures through the delegated backend.
func (a *Adapter) EvaluateFixtures(ctx context.Context, fixtures []api.PolicyFixture) []FixtureResult {
results := make([]FixtureResult, 0, len(fixtures))
for _, fixture := range fixtures {
decision, err := a.Check(ctx, fixture.Request)
actual := api.DecisionExpectation{}
if err == nil {
actual = api.DecisionExpectation{
Effect: decision.Effect,
Reason: decision.Reason,
Obligations: decision.Obligations,
ConformanceFindings: conformanceFindings(decision),
}
}
result := FixtureResult{
ID: fixture.ID,
Expected: fixture.Expect,
Actual: actual,
Passed: err == nil && expectationMatches(fixture.Expect, actual),
}
if err != nil {
result.Error = err.Error()
}
results = append(results, result)
}
return results
}
func (a *Adapter) buildEvaluationRequest(request api.CheckRequest) (EvaluationRequest, error) {
if request.Subject.ID == "" || request.Action == "" || request.Resource.ID == "" {
return EvaluationRequest{}, fmt.Errorf("subject id, action, and resource id are required")
}
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
evaluation := EvaluationRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: request.Resource,
Context: copyMap(request.Context),
CaringContext: request.CaringContext,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
Language: a.language,
}
evaluation.Input = CanonicalInput(evaluation)
return evaluation, nil
}
// CanonicalInput returns the policy-language-neutral input shape.
func CanonicalInput(request EvaluationRequest) map[string]any {
input := map[string]any{
"subject": request.Subject,
"action": request.Action,
"resource": request.Resource,
"context": copyMap(request.Context),
"policy": map[string]any{
"package": request.PolicyPackage,
"version": request.PolicyVersion,
},
}
if request.CaringContext != nil {
input["caring_context"] = request.CaringContext
}
return input
}
func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationRequest, result EvaluationResult) api.DecisionEnvelope {
effect := result.Effect
if effect == "" {
effect = api.DecisionEffectDeny
}
reason := result.Reason
if reason == "" {
reason = string(effect)
}
policyPackage := firstNonEmpty(result.PolicyPackage, a.policyPackage)
policyVersion := firstNonEmpty(result.PolicyVersion, evaluation.PolicyVersion, a.policyVersion)
diagnostics := copyMap(result.Diagnostics)
addRuleDiagnostics(diagnostics, a.backendName, evaluation, "")
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: effect,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource,
Subject: request.Subject,
Obligations: append([]api.Obligation(nil), result.Obligations...),
Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), a.caring, result.ConformanceFindings),
}
envelope.ID = decisionID(a.backendName, policyPackage, policyVersion, request, effect, reason)
return envelope
}
func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation EvaluationRequest, kind FailureKind, err error) api.DecisionEnvelope {
reason := failureReason(kind)
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: api.DecisionEffectDeny,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: reason,
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err),
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, request.CaringContext, a.caring, []api.CaringConformanceFinding{failureFinding(kind)}),
}
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope
}
func caringDecisionMetadata(
request api.CheckRequest,
descriptor *api.CaringAccessDescriptor,
policyMetadata api.CaringPolicyMetadata,
findings []api.CaringConformanceFinding,
) *api.CaringDecisionMetadata {
profile := firstNonEmpty(policyMetadata.Profile, api.CaringProfileCaring040RC2)
if descriptor != nil && descriptor.Profile != "" {
profile = descriptor.Profile
}
metadata := &api.CaringDecisionMetadata{
Profile: profile,
ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...),
}
metadata.ConformanceFindings = append(metadata.ConformanceFindings, policyMetadataFindings(policyMetadata)...)
if descriptor == nil {
metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{
Code: "RULE-CARING-DESCRIPTOR-MISSING",
Severity: "warning",
Message: "delegated rule backend result did not include a CARING descriptor",
Fields: []string{"caring_context"},
})
return metadata
}
descriptorCopy := *descriptor
metadata.Descriptor = &descriptorCopy
metadata.RestrictionsEvaluated = append([]api.Restriction(nil), descriptor.Restrictions...)
metadata.ExposureModes = append([]api.ExposureMode(nil), descriptor.ExposureModes...)
metadata.DerivedCapabilities = append([]api.CaringDerivedCapability(nil), descriptor.DerivedCapabilities...)
if descriptor.ExposureEvent != "" {
scope := descriptor.Scope
metadata.ExposureEvent = &api.CaringExposureEvent{
ID: decisionID("rule", "", "", request, api.DecisionEffectAllow, string(descriptor.ExposureEvent)),
Type: descriptor.ExposureEvent,
Actor: request.Subject.ID,
Subject: request.Subject.ID,
Descriptor: &descriptorCopy,
Scope: &scope,
Planes: append([]api.Plane(nil), descriptor.Planes...),
CapabilitiesUsed: append([]api.Capability(nil), descriptor.Capabilities...),
ExposureModes: append([]api.ExposureMode(nil), descriptor.ExposureModes...),
Reason: "delegated rule decision carries CARING exposure event hook",
AuthoritySource: EvaluatorName,
}
}
return metadata
}
func policyMetadataFindings(metadata api.CaringPolicyMetadata) []api.CaringConformanceFinding {
if metadata.Profile == "" {
return nil
}
var findings []api.CaringConformanceFinding
addMissing := func(empty bool, field, label string) {
if empty {
findings = append(findings, api.CaringConformanceFinding{
Code: "RULE-CARING-METADATA-GAP",
Severity: "warning",
Message: "backend-native rule artifact does not directly represent CARING " + label,
Fields: []string{field},
})
}
}
addMissing(len(metadata.CanonicalRoles) == 0, "caring.canonical_roles", "canonical roles")
addMissing(len(metadata.OrganizationRelations) == 0, "caring.organization_relations", "organization relations")
addMissing(len(metadata.Scopes) == 0, "caring.scopes", "scopes")
addMissing(len(metadata.Planes) == 0, "caring.planes", "planes")
addMissing(len(metadata.Capabilities) == 0, "caring.capabilities", "capabilities")
addMissing(len(metadata.ExposureModes) == 0, "caring.exposure_modes", "exposure modes")
return findings
}
func failureDiagnostics(backendName string, request EvaluationRequest, kind FailureKind, err error) map[string]any {
diagnostics := map[string]any{}
addRuleDiagnostics(diagnostics, backendName, request, string(kind))
if err != nil {
diagnostics["error"] = err.Error()
}
return diagnostics
}
func addRuleDiagnostics(diagnostics map[string]any, backendName string, request EvaluationRequest, failure string) {
diagnostics["adapter"] = "rule"
diagnostics["backend"] = backendName
diagnostics["mode"] = DelegatedMode
diagnostics["language"] = string(request.Language)
if failure != "" {
diagnostics["rule_failure"] = failure
}
if request.PolicyPackage != "" {
diagnostics["policy_package"] = request.PolicyPackage
diagnostics["policy_version"] = request.PolicyVersion
}
}
func failureReason(kind FailureKind) string {
switch kind {
case FailureStalePolicy:
return "rule_policy_stale"
case FailurePartialResult:
return "rule_partial_result"
case FailureInvalidRequest:
return "rule_request_incomplete"
case FailureUnsupportedPolicy:
return "rule_policy_unsupported"
default:
return "rule_backend_unavailable"
}
}
func failureFinding(kind FailureKind) api.CaringConformanceFinding {
code := "RULE-BACKEND-UNAVAILABLE"
message := "Rule backend was unavailable; flex-auth denied the delegated request fail-closed."
switch kind {
case FailureStalePolicy:
code = "RULE-POLICY-STALE"
message = "Rule backend policy version was stale; flex-auth denied fail-closed."
case FailurePartialResult:
code = "RULE-PARTIAL-RESULT"
message = "Rule backend returned a partial result; flex-auth denied fail-closed."
case FailureInvalidRequest:
code = "RULE-REQUEST-INCOMPLETE"
message = "Request could not be translated to canonical rule input; flex-auth denied fail-closed."
case FailureUnsupportedPolicy:
code = "RULE-POLICY-UNSUPPORTED"
message = "Rule backend cannot represent the imported policy artifact; flex-auth denied fail-closed."
}
return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message}
}
func conformanceFindings(decision api.DecisionEnvelope) []api.CaringConformanceFinding {
if decision.Caring == nil {
return nil
}
return decision.Caring.ConformanceFindings
}
func expectationMatches(expected, actual api.DecisionExpectation) bool {
if expected.Effect != actual.Effect {
return false
}
if expected.Reason != "" && expected.Reason != actual.Reason {
return false
}
if len(expected.Obligations) > 0 && !reflect.DeepEqual(expected.Obligations, actual.Obligations) {
return false
}
return true
}
func firstDescriptor(values ...*api.CaringAccessDescriptor) *api.CaringAccessDescriptor {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func decisionID(backendName, policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason string) string {
data, _ := json.Marshal(struct {
Adapter string `json:"adapter"`
Backend string `json:"backend"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
Request api.CheckRequest `json:"request"`
Effect api.DecisionEffect `json:"effect"`
Reason string `json:"reason"`
}{
Adapter: EvaluatorName,
Backend: backendName,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
Request: request,
Effect: effect,
Reason: reason,
})
sum := sha256.Sum256(data)
return "decision:rule:" + hex.EncodeToString(sum[:8])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,269 @@
package rule_test
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/rule"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestCanonicalInputFromCheck(t *testing.T) {
adapter := newAdapter(t, &fakeBackend{})
decision, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:input",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
Context: map[string]any{"purpose": "support"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if decision.Diagnostics["input_seen"] != true {
t.Fatalf("backend did not receive canonical input: %+v", decision.Diagnostics)
}
}
func TestPolicyArtifactFromPackagePreservesRegoAndFixtures(t *testing.T) {
pkg := loadPolicy(t)
artifact := rule.PolicyArtifactFromPackage(pkg)
if artifact.ID != "markitect.documents.internal-read" || artifact.Version != "v1" {
t.Fatalf("artifact metadata = %+v", artifact)
}
if artifact.Language != rule.LanguageRego {
t.Fatalf("Language = %q", artifact.Language)
}
if artifact.Module != pkg.RegoModule {
t.Fatal("Rego module changed during artifact creation")
}
if len(artifact.Fixtures) != len(pkg.Fixtures) {
t.Fatalf("fixtures = %d; want %d", len(artifact.Fixtures), len(pkg.Fixtures))
}
if artifact.Caring.Profile != api.CaringProfileCaring040RC2 {
t.Fatalf("Caring profile = %q", artifact.Caring.Profile)
}
}
func TestAdapterCheckWrapsRuleResult(t *testing.T) {
backend := &fakeBackend{
result: rule.EvaluationResult{
Effect: api.DecisionEffectRedact,
Reason: "masked_internal_document",
MatchedRule: "rule.mask_internal",
PolicyVersion: "v2",
Obligations: []api.Obligation{
{Type: "mask_fields", Parameters: map[string]any{"fields": []string{"email"}}},
},
Diagnostics: map[string]any{"backend_trace": "trace-1"},
CaringDescriptor: caringDescriptor(),
ConformanceFindings: []api.CaringConformanceFinding{
{Code: "RULE-MASKED", Severity: "info", Message: "masked internal document"},
},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:redact",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" {
t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
}
if got.Provenance.Evaluator != "rule-pdp/opa" || got.MatchedPolicyVersion != "v2" {
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
}
if len(got.Obligations) != 1 || got.Obligations[0].Type != "mask_fields" {
t.Fatalf("obligations = %+v", got.Obligations)
}
if got.Caring == nil || got.Caring.Descriptor == nil || got.Caring.ExposureEvent == nil {
t.Fatalf("CARING metadata = %+v", got.Caring)
}
}
func TestAdapterFailsClosedOnStalePolicy(t *testing.T) {
backend := &fakeBackend{
err: rule.NewBackendError(rule.FailureStalePolicy, "evaluate", errors.New("policy revision too old")),
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:stale",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != "rule_policy_stale" {
t.Fatalf("decision = %s/%s; want stale deny", got.Effect, got.Reason)
}
if got.Diagnostics["rule_failure"] != "stale_policy" {
t.Fatalf("diagnostics = %+v", got.Diagnostics)
}
if got.Caring.ConformanceFindings[0].Code != "RULE-POLICY-STALE" {
t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0])
}
}
func TestBatchCheckPreservesOrder(t *testing.T) {
backend := &fakeBackend{
batch: []rule.EvaluationResult{
{Effect: api.DecisionEffectAllow, Reason: "first"},
{Effect: api.DecisionEffectDeny, Reason: "second"},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.BatchCheck(context.Background(), api.BatchCheckRequest{
ID: "batch:rule",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resources: []api.ResourceRef{
{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
{ID: "document:missing", Type: "document", System: "markitect-tool"},
},
})
if err != nil {
t.Fatalf("BatchCheck: %v", err)
}
if len(got) != 2 || got[0].Reason != "first" || got[1].Reason != "second" {
t.Fatalf("batch = %+v", got)
}
}
func TestEvaluateFixturesComparesExpectations(t *testing.T) {
backend := &fakeBackend{
result: rule.EvaluationResult{Effect: api.DecisionEffectDeny, Reason: "no_matching_rule"},
}
adapter := newAdapter(t, backend)
results := adapter.EvaluateFixtures(context.Background(), []api.PolicyFixture{
{
ID: "fixture:deny",
Request: api.CheckRequest{
Subject: api.SubjectRef{ID: "user:bob"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
},
Expect: api.DecisionExpectation{Effect: api.DecisionEffectDeny, Reason: "no_matching_rule"},
},
})
if len(results) != 1 || !results[0].Passed {
t.Fatalf("fixture results = %+v", results)
}
}
func TestImportPolicyDelegatesArtifact(t *testing.T) {
backend := &fakeBackend{}
adapter := newAdapter(t, backend)
pkg := loadPolicy(t)
report, err := adapter.ImportPolicy(context.Background(), pkg)
if err != nil {
t.Fatalf("ImportPolicy: %v", err)
}
if report.ArtifactID != pkg.Metadata.ID || backend.artifact.Module != pkg.RegoModule {
t.Fatalf("report = %+v artifact = %+v", report, backend.artifact)
}
}
func newAdapter(t *testing.T, backend *fakeBackend) *rule.Adapter {
t.Helper()
adapter, err := rule.New(backend, rule.Options{
BackendName: "opa",
PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1",
Language: rule.LanguageRego,
Caring: api.CaringPolicyMetadata{
Profile: api.CaringProfileCaring040RC2,
CanonicalRoles: []api.CanonicalRole{api.CanonicalRoleDoer},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
return adapter
}
func loadPolicy(t *testing.T) *policy.Package {
t.Helper()
pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "..", "examples", "caring", "policy_package.md"))
if err != nil {
t.Fatalf("LoadAndValidateFile: %v", err)
}
return pkg
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:rule-reader",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeHuman,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{Level: api.ScopeLevelResource, ID: "document:internal-note"},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
Restrictions: []api.Restriction{api.RestrictionExportBlocked},
ExposureEvent: api.ExposureEventSupport,
}
}
type fakeBackend struct {
result rule.EvaluationResult
err error
batch []rule.EvaluationResult
artifact rule.PolicyArtifact
}
func (b *fakeBackend) Evaluate(_ context.Context, request rule.EvaluationRequest) (rule.EvaluationResult, error) {
if request.Input["subject"] != nil && b.result.Diagnostics == nil {
b.result.Diagnostics = map[string]any{"input_seen": true}
}
return b.result, b.err
}
func (b *fakeBackend) BatchEvaluate(_ context.Context, requests []rule.EvaluationRequest) ([]rule.EvaluationResult, error) {
if b.batch != nil {
return b.batch, nil
}
results := make([]rule.EvaluationResult, len(requests))
for i := range results {
results[i] = b.result
}
return results, b.err
}
func (b *fakeBackend) ImportPolicy(_ context.Context, artifact rule.PolicyArtifact) (rule.PolicyImportReport, error) {
b.artifact = artifact
return rule.PolicyImportReport{
ArtifactID: artifact.ID,
Version: artifact.Version,
Language: artifact.Language,
BackendRef: "opa:" + artifact.ID,
}, nil
}
func (b *fakeBackend) Health(context.Context) error {
return nil
}

View File

@@ -0,0 +1,3 @@
// Package rule defines the delegated rule-PDP boundary for OPA/Rego,
// Cedar-style policy services, and other policy-language backends.
package rule

View File

@@ -0,0 +1,26 @@
package rule
import (
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
// PolicyArtifactFromPackage preserves the extracted Rego module and metadata
// from a flex-auth Rego-in-Markdown package.
func PolicyArtifactFromPackage(pkg *policy.Package) PolicyArtifact {
tests := make([]string, 0, len(pkg.TestBlocks))
for _, block := range pkg.TestBlocks {
tests = append(tests, block.Body)
}
return PolicyArtifact{
ID: pkg.Metadata.ID,
Version: pkg.Metadata.Version,
Language: LanguageRego,
Package: pkg.Metadata.Package,
Module: pkg.RegoModule,
Tests: tests,
Fixtures: append([]api.PolicyFixture(nil), pkg.Fixtures...),
Caring: pkg.Metadata.Caring,
Metadata: copyMap(pkg.Metadata.Metadata),
}
}

View File

@@ -0,0 +1,148 @@
package rule
import (
"context"
"errors"
"fmt"
"github.com/netkingdom/flex-auth/pkg/api"
)
const (
// EvaluatorName is recorded in delegated rule-PDP provenance.
EvaluatorName = "rule-pdp"
// DelegatedMode is the stable mode used for delegated rule decisions.
DelegatedMode = "delegated"
)
// Language identifies a backend-native policy language.
type Language string
const (
LanguageRego Language = "rego"
LanguageCedar Language = "cedar"
)
// Backend is the protocol boundary for rule-oriented PDPs.
type Backend interface {
Evaluate(context.Context, EvaluationRequest) (EvaluationResult, error)
BatchEvaluate(context.Context, []EvaluationRequest) ([]EvaluationResult, error)
ImportPolicy(context.Context, PolicyArtifact) (PolicyImportReport, error)
Health(context.Context) error
}
// Options configures the rule adapter.
type Options struct {
BackendName string
PolicyPackage string
PolicyVersion string
Language Language
Caring api.CaringPolicyMetadata
}
// EvaluationRequest is the canonical rule-PDP request.
type EvaluationRequest struct {
ID string `json:"id,omitempty"`
Subject api.SubjectRef `json:"subject"`
Action string `json:"action"`
Resource api.ResourceRef `json:"resource"`
Context map[string]any `json:"context,omitempty"`
CaringContext *api.CaringAccessDescriptor `json:"caring_context,omitempty"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
Language Language `json:"language,omitempty"`
Input map[string]any `json:"input,omitempty"`
}
// EvaluationResult is a backend-native policy result before envelope wrapping.
type EvaluationResult struct {
Effect api.DecisionEffect
Reason string
MatchedRule string
PolicyPackage string
PolicyVersion string
Obligations []api.Obligation
Diagnostics map[string]any
CaringDescriptor *api.CaringAccessDescriptor
ConformanceFindings []api.CaringConformanceFinding
StalePolicy bool
PartialResult bool
}
// PolicyArtifact is the backend-neutral policy import shape.
type PolicyArtifact struct {
ID string
Version string
Language Language
Package string
Module string
Tests []string
Fixtures []api.PolicyFixture
Caring api.CaringPolicyMetadata
Metadata map[string]any
}
// PolicyImportReport records backend policy versioning metadata.
type PolicyImportReport struct {
ArtifactID string
Version string
Language Language
BackendRef string
}
// FixtureResult records one delegated fixture run.
type FixtureResult struct {
ID string
Passed bool
Expected api.DecisionExpectation
Actual api.DecisionExpectation
Error string
}
// FailureKind classifies fail-closed rule-PDP decisions.
type FailureKind string
const (
FailureUnavailable FailureKind = "unavailable"
FailureStalePolicy FailureKind = "stale_policy"
FailurePartialResult FailureKind = "partial_result"
FailureInvalidRequest FailureKind = "invalid_request"
FailureUnsupportedPolicy FailureKind = "unsupported_policy"
)
// BackendError wraps rule-backend failures with adapter semantics.
type BackendError struct {
Kind FailureKind
Op string
Err error
}
func (e *BackendError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
return fmt.Sprintf("rule backend %s failed: %s", e.Op, e.Kind)
}
return fmt.Sprintf("rule backend %s failed: %s: %v", e.Op, e.Kind, e.Err)
}
func (e *BackendError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// NewBackendError classifies a rule-backend failure.
func NewBackendError(kind FailureKind, op string, err error) error {
return &BackendError{Kind: kind, Op: op, Err: err}
}
func failureKind(err error) FailureKind {
var backend *BackendError
if errors.As(err, &backend) && backend.Kind != "" {
return backend.Kind
}
return FailureUnavailable
}

View File

@@ -98,7 +98,7 @@ Define and implement adapter contracts for OpenFGA and SpiceDB-style checks:
```task
id: FLEX-WP-0004-T003
status: todo
status: done
priority: high
state_hub_task_id: "4e4e5e45-c05a-4a31-8126-f0c7676b1e6c"
```