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
|
||||
id: FLEX-WP-0004-T003
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4e4e5e45-c05a-4a31-8126-f0c7676b1e6c"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user