generated from coulomb/repo-seed
Add rule PDP adapter boundary
This commit is contained in:
103
docs/rule-pdp-adapter-boundary.md
Normal file
103
docs/rule-pdp-adapter-boundary.md
Normal 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.
|
||||||
461
internal/adapters/rule/adapter.go
Normal file
461
internal/adapters/rule/adapter.go
Normal 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
|
||||||
|
}
|
||||||
269
internal/adapters/rule/adapter_test.go
Normal file
269
internal/adapters/rule/adapter_test.go
Normal 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
|
||||||
|
}
|
||||||
3
internal/adapters/rule/doc.go
Normal file
3
internal/adapters/rule/doc.go
Normal 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
|
||||||
26
internal/adapters/rule/policy_artifact.go
Normal file
26
internal/adapters/rule/policy_artifact.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
148
internal/adapters/rule/types.go
Normal file
148
internal/adapters/rule/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ Define and implement adapter contracts for OpenFGA and SpiceDB-style checks:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0004-T003
|
id: FLEX-WP-0004-T003
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "4e4e5e45-c05a-4a31-8126-f0c7676b1e6c"
|
state_hub_task_id: "4e4e5e45-c05a-4a31-8126-f0c7676b1e6c"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user