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 }