From ad4895187bd5513d9081109c82b1534e1ee1dc97 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 17 May 2026 07:13:27 +0200 Subject: [PATCH] Add rule PDP adapter boundary --- docs/rule-pdp-adapter-boundary.md | 103 ++++ internal/adapters/rule/adapter.go | 461 ++++++++++++++++++ internal/adapters/rule/adapter_test.go | 269 ++++++++++ internal/adapters/rule/doc.go | 3 + internal/adapters/rule/policy_artifact.go | 26 + internal/adapters/rule/types.go | 148 ++++++ ...04-delegated-pdp-and-directory-adapters.md | 2 +- 7 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 docs/rule-pdp-adapter-boundary.md create mode 100644 internal/adapters/rule/adapter.go create mode 100644 internal/adapters/rule/adapter_test.go create mode 100644 internal/adapters/rule/doc.go create mode 100644 internal/adapters/rule/policy_artifact.go create mode 100644 internal/adapters/rule/types.go diff --git a/docs/rule-pdp-adapter-boundary.md b/docs/rule-pdp-adapter-boundary.md new file mode 100644 index 0000000..0169e18 --- /dev/null +++ b/docs/rule-pdp-adapter-boundary.md @@ -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. diff --git a/internal/adapters/rule/adapter.go b/internal/adapters/rule/adapter.go new file mode 100644 index 0000000..756f487 --- /dev/null +++ b/internal/adapters/rule/adapter.go @@ -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 +} diff --git a/internal/adapters/rule/adapter_test.go b/internal/adapters/rule/adapter_test.go new file mode 100644 index 0000000..0d523c9 --- /dev/null +++ b/internal/adapters/rule/adapter_test.go @@ -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 +} diff --git a/internal/adapters/rule/doc.go b/internal/adapters/rule/doc.go new file mode 100644 index 0000000..4ab394a --- /dev/null +++ b/internal/adapters/rule/doc.go @@ -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 diff --git a/internal/adapters/rule/policy_artifact.go b/internal/adapters/rule/policy_artifact.go new file mode 100644 index 0000000..faef352 --- /dev/null +++ b/internal/adapters/rule/policy_artifact.go @@ -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), + } +} diff --git a/internal/adapters/rule/types.go b/internal/adapters/rule/types.go new file mode 100644 index 0000000..922073a --- /dev/null +++ b/internal/adapters/rule/types.go @@ -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 +} diff --git a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md index c28ea45..3c2034d 100644 --- a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md +++ b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md @@ -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" ```