generated from coulomb/repo-seed
Add rule PDP adapter boundary
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user