generated from coulomb/repo-seed
466 lines
14 KiB
Go
466 lines
14 KiB
Go
package decision
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/netkingdom/flex-auth/internal/policy"
|
|
"github.com/netkingdom/flex-auth/internal/registry"
|
|
"github.com/netkingdom/flex-auth/pkg/api"
|
|
)
|
|
|
|
// Engine evaluates deterministic standalone authorization checks against a
|
|
// local registry and one validated policy package.
|
|
type Engine struct {
|
|
store *registry.Store
|
|
policy *policy.Package
|
|
mu sync.RWMutex
|
|
history map[string]api.DecisionEnvelope
|
|
log DecisionRecorder
|
|
}
|
|
|
|
// DecisionRecorder persists decision envelopes.
|
|
type DecisionRecorder interface {
|
|
Append(api.DecisionEnvelope) error
|
|
}
|
|
|
|
// ListAllowedRequest describes a deterministic list_allowed call.
|
|
type ListAllowedRequest struct {
|
|
Subject api.SubjectRef `json:"subject"`
|
|
Action string `json:"action"`
|
|
System string `json:"system,omitempty"`
|
|
ResourceType string `json:"resource_type,omitempty"`
|
|
Filters map[string]any `json:"filters,omitempty"`
|
|
Context map[string]any `json:"context,omitempty"`
|
|
PolicyVersion string `json:"policy_version,omitempty"`
|
|
}
|
|
|
|
// Explanation is a compact explanation view over a recorded decision.
|
|
type Explanation struct {
|
|
DecisionID string `json:"decision_id"`
|
|
Effect api.DecisionEffect `json:"effect"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Summary string `json:"summary"`
|
|
Subject api.SubjectRef `json:"subject"`
|
|
Resource api.ResourceRef `json:"resource"`
|
|
PolicyPackage string `json:"policy_package,omitempty"`
|
|
PolicyVersion string `json:"policy_version,omitempty"`
|
|
MatchedRule string `json:"matched_rule,omitempty"`
|
|
Diagnostics map[string]any `json:"diagnostics,omitempty"`
|
|
Caring *api.CaringDecisionMetadata `json:"caring,omitempty"`
|
|
}
|
|
|
|
// NewEngine creates a standalone decision engine.
|
|
func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, error) {
|
|
if store == nil {
|
|
return nil, fmt.Errorf("registry store is required")
|
|
}
|
|
if policyPackage == nil {
|
|
return nil, fmt.Errorf("policy package is required")
|
|
}
|
|
if !policyPackage.Valid {
|
|
return nil, fmt.Errorf("policy package %q is not valid", policyPackage.Metadata.ID)
|
|
}
|
|
return &Engine{
|
|
store: store,
|
|
policy: policyPackage,
|
|
history: make(map[string]api.DecisionEnvelope),
|
|
}, nil
|
|
}
|
|
|
|
// SetDecisionLog attaches a local decision recorder to the engine.
|
|
func (e *Engine) SetDecisionLog(log DecisionRecorder) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.log = log
|
|
}
|
|
|
|
// Check evaluates one subject/action/resource request.
|
|
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
|
normalized, facts := e.normalizeRequest(request)
|
|
|
|
expectation, err := e.policy.Evaluate(ctx, normalized)
|
|
if err != nil {
|
|
return api.DecisionEnvelope{}, err
|
|
}
|
|
|
|
decision := e.envelope(normalized, expectation, facts)
|
|
if err := e.recordDecision(decision); err != nil {
|
|
return api.DecisionEnvelope{}, err
|
|
}
|
|
return decision, nil
|
|
}
|
|
|
|
// BatchCheck evaluates one subject/action/context tuple against resources in
|
|
// request order.
|
|
func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
|
|
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
|
|
for _, resource := range request.Resources {
|
|
decision, err := e.Check(ctx, api.CheckRequest{
|
|
ID: request.ID,
|
|
Subject: request.Subject,
|
|
Action: request.Action,
|
|
Resource: resource,
|
|
Context: request.Context,
|
|
PolicyVersion: request.PolicyVersion,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decisions = append(decisions, decision)
|
|
}
|
|
return decisions, nil
|
|
}
|
|
|
|
// ListAllowed evaluates candidate resources and returns only allow decisions.
|
|
func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) {
|
|
candidates := e.store.ResourceRefs(request.System, request.ResourceType)
|
|
allowed := make([]api.DecisionEnvelope, 0, len(candidates))
|
|
for _, resource := range candidates {
|
|
if !resourceMatchesFilters(resource, request.Filters) {
|
|
continue
|
|
}
|
|
decision, err := e.Check(ctx, api.CheckRequest{
|
|
Subject: request.Subject,
|
|
Action: request.Action,
|
|
Resource: resource,
|
|
Context: request.Context,
|
|
PolicyVersion: request.PolicyVersion,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if decision.Effect == api.DecisionEffectAllow {
|
|
allowed = append(allowed, decision)
|
|
}
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
// Explain returns a CARING-aware explanation for a decision recorded by this
|
|
// engine instance. P2.6 replaces this in-memory history with the local log.
|
|
func (e *Engine) Explain(decisionID string) (Explanation, error) {
|
|
e.mu.RLock()
|
|
decision, ok := e.history[decisionID]
|
|
e.mu.RUnlock()
|
|
if !ok {
|
|
return Explanation{}, fmt.Errorf("decision %q not found", decisionID)
|
|
}
|
|
|
|
return Explanation{
|
|
DecisionID: decision.ID,
|
|
Effect: decision.Effect,
|
|
Reason: decision.Reason,
|
|
Summary: explanationSummary(decision),
|
|
Subject: decision.Subject,
|
|
Resource: decision.Resource,
|
|
PolicyPackage: decision.Provenance.PolicyPackage,
|
|
PolicyVersion: decision.Provenance.PolicyVersion,
|
|
MatchedRule: decision.MatchedRule,
|
|
Diagnostics: decision.Diagnostics,
|
|
Caring: decision.Caring,
|
|
}, nil
|
|
}
|
|
|
|
type registryFacts struct {
|
|
subjectFound bool
|
|
resourceFound bool
|
|
subject api.Subject
|
|
resource api.Resource
|
|
matchedRelationship string
|
|
descriptor *api.CaringAccessDescriptor
|
|
}
|
|
|
|
func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, registryFacts) {
|
|
normalized := request
|
|
facts := registryFacts{}
|
|
|
|
if subject, ok := e.store.Subject(request.Subject.ID); ok {
|
|
facts.subjectFound = true
|
|
facts.subject = subject
|
|
normalized.Subject = enrichSubjectRef(request.Subject, subject)
|
|
}
|
|
|
|
if resource, ok := e.store.Resource(request.Resource.System, request.Resource.ID); ok {
|
|
facts.resourceFound = true
|
|
facts.resource = resource
|
|
normalized.Resource = enrichResourceRef(request.Resource, resource)
|
|
}
|
|
|
|
if normalized.CaringContext != nil {
|
|
descriptor := *normalized.CaringContext
|
|
facts.descriptor = &descriptor
|
|
return normalized, facts
|
|
}
|
|
|
|
if descriptor, relationshipID := e.matchCaringDescriptor(normalized, facts); descriptor != nil {
|
|
facts.descriptor = descriptor
|
|
facts.matchedRelationship = relationshipID
|
|
normalized.CaringContext = descriptor
|
|
} else if facts.resourceFound && facts.resource.Caring != nil {
|
|
descriptor := *facts.resource.Caring
|
|
facts.descriptor = &descriptor
|
|
normalized.CaringContext = &descriptor
|
|
}
|
|
|
|
return normalized, facts
|
|
}
|
|
|
|
func (e *Engine) matchCaringDescriptor(request api.CheckRequest, facts registryFacts) (*api.CaringAccessDescriptor, string) {
|
|
candidates := []string{request.Subject.ID}
|
|
if facts.subjectFound {
|
|
candidates = append(candidates, facts.subject.Groups...)
|
|
}
|
|
|
|
for _, relationship := range e.store.RelationshipsForObject(request.Resource.ID) {
|
|
if relationship.Caring == nil {
|
|
continue
|
|
}
|
|
if !slices.Contains(candidates, relationship.Subject) {
|
|
continue
|
|
}
|
|
if relationship.System != "" && relationship.System != request.Resource.System {
|
|
continue
|
|
}
|
|
if relationship.Tenant != "" && request.Resource.Tenant != "" && relationship.Tenant != request.Resource.Tenant {
|
|
continue
|
|
}
|
|
descriptor := *relationship.Caring
|
|
return &descriptor, relationship.ID
|
|
}
|
|
return nil, ""
|
|
}
|
|
|
|
func enrichSubjectRef(ref api.SubjectRef, subject api.Subject) api.SubjectRef {
|
|
out := ref
|
|
if out.Type == "" {
|
|
out.Type = subject.Type
|
|
}
|
|
if out.Tenant == "" {
|
|
out.Tenant = subject.Tenant
|
|
}
|
|
out.Attributes = copyMap(out.Attributes)
|
|
addAttribute(out.Attributes, "display_name", subject.DisplayName)
|
|
addAttribute(out.Attributes, "organization_relation", subject.OrganizationRelation)
|
|
addAttribute(out.Attributes, "roles", subject.Roles)
|
|
addAttribute(out.Attributes, "groups", subject.Groups)
|
|
addAttributes(out.Attributes, subject.Claims)
|
|
addAttributes(out.Attributes, subject.Metadata)
|
|
return out
|
|
}
|
|
|
|
func enrichResourceRef(ref api.ResourceRef, resource api.Resource) api.ResourceRef {
|
|
out := ref
|
|
if out.Type == "" {
|
|
out.Type = resource.Type
|
|
}
|
|
out.Attributes = copyMap(out.Attributes)
|
|
addAttribute(out.Attributes, "path", resource.Path)
|
|
addAttribute(out.Attributes, "parent", resource.Parent)
|
|
addAttribute(out.Attributes, "labels", resource.Labels)
|
|
addAttribute(out.Attributes, "trust_zone", resource.TrustZone)
|
|
addAttribute(out.Attributes, "owner", resource.Owner)
|
|
addAttributes(out.Attributes, resource.Attributes)
|
|
return out
|
|
}
|
|
|
|
func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope {
|
|
envelope := api.DecisionEnvelope{
|
|
RequestID: request.ID,
|
|
Effect: expectation.Effect,
|
|
Reason: expectation.Reason,
|
|
MatchedPolicyVersion: e.policy.Metadata.Version,
|
|
MatchedRule: expectation.Reason,
|
|
Resource: request.Resource,
|
|
Subject: request.Subject,
|
|
Obligations: expectation.Obligations,
|
|
Diagnostics: map[string]any{
|
|
"action": request.Action,
|
|
"policy_package": e.policy.Metadata.ID,
|
|
"policy_status": e.policy.Metadata.Status,
|
|
"registry_subject": facts.subjectFound,
|
|
"registry_resource": facts.resourceFound,
|
|
"matched_relationship": facts.matchedRelationship,
|
|
},
|
|
Provenance: api.DecisionProvenance{
|
|
Evaluator: "flex-auth/local",
|
|
Mode: "standalone",
|
|
PolicyPackage: e.policy.Metadata.ID,
|
|
PolicyVersion: e.policy.Metadata.Version,
|
|
},
|
|
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
|
|
}
|
|
envelope.ID = decisionID(e.policy.Metadata, request, envelope)
|
|
return envelope
|
|
}
|
|
|
|
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.history[decision.ID] = decision
|
|
if e.log != nil {
|
|
return e.log.Append(decision)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
|
|
profile := e.policy.Metadata.Caring.Profile
|
|
if descriptor != nil && descriptor.Profile != "" {
|
|
profile = descriptor.Profile
|
|
}
|
|
|
|
metadata := &api.CaringDecisionMetadata{
|
|
Profile: profile,
|
|
ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...),
|
|
}
|
|
if descriptor == nil {
|
|
metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{
|
|
Code: "CARING-DESCRIPTOR-MISSING",
|
|
Severity: "warning",
|
|
Message: "no CARING descriptor matched the request",
|
|
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...)
|
|
return metadata
|
|
}
|
|
|
|
func decisionID(metadata api.PolicyPackageMetadata, request api.CheckRequest, envelope api.DecisionEnvelope) string {
|
|
data, _ := json.Marshal(struct {
|
|
PolicyID string `json:"policy_id"`
|
|
PolicyVersion string `json:"policy_version"`
|
|
Request api.CheckRequest `json:"request"`
|
|
Effect api.DecisionEffect `json:"effect"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}{
|
|
PolicyID: metadata.ID,
|
|
PolicyVersion: metadata.Version,
|
|
Request: request,
|
|
Effect: envelope.Effect,
|
|
Reason: envelope.Reason,
|
|
})
|
|
sum := sha256.Sum256(data)
|
|
return "decision:" + hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func addAttributes(target map[string]any, attrs map[string]any) {
|
|
for key, value := range attrs {
|
|
addAttribute(target, key, value)
|
|
}
|
|
}
|
|
|
|
func addAttribute(target map[string]any, key string, value any) {
|
|
if isEmptyAttribute(value) {
|
|
return
|
|
}
|
|
if _, exists := target[key]; !exists {
|
|
target[key] = value
|
|
}
|
|
}
|
|
|
|
func resourceMatchesFilters(resource api.ResourceRef, filters map[string]any) bool {
|
|
for key, want := range filters {
|
|
var got any
|
|
switch key {
|
|
case "id":
|
|
got = resource.ID
|
|
case "type", "resource_type":
|
|
got = resource.Type
|
|
case "system":
|
|
got = resource.System
|
|
case "tenant":
|
|
got = resource.Tenant
|
|
default:
|
|
got = resource.Attributes[key]
|
|
}
|
|
if !valuesEqual(got, want) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func valuesEqual(got, want any) bool {
|
|
if reflect.DeepEqual(got, want) {
|
|
return true
|
|
}
|
|
return fmt.Sprint(got) == fmt.Sprint(want)
|
|
}
|
|
|
|
func explanationSummary(decision api.DecisionEnvelope) string {
|
|
action, _ := decision.Diagnostics["action"].(string)
|
|
actor := decision.Subject.ID
|
|
capability := action
|
|
plane := ""
|
|
if decision.Caring != nil && decision.Caring.Descriptor != nil {
|
|
descriptor := decision.Caring.Descriptor
|
|
if descriptor.OrganizationRelation != "" || descriptor.CanonicalRole != "" {
|
|
actor = strings.TrimSpace(fmt.Sprintf("%s %s", descriptor.OrganizationRelation, descriptor.CanonicalRole))
|
|
}
|
|
if len(descriptor.Capabilities) > 0 {
|
|
capability = string(descriptor.Capabilities[0])
|
|
}
|
|
if len(descriptor.Planes) > 0 {
|
|
plane = string(descriptor.Planes[0]) + " Plane "
|
|
}
|
|
}
|
|
if capability == "" {
|
|
capability = "access"
|
|
}
|
|
|
|
verb := "may"
|
|
switch decision.Effect {
|
|
case api.DecisionEffectDeny:
|
|
verb = "may not"
|
|
case api.DecisionEffectRedact:
|
|
verb = "receives redacted"
|
|
case api.DecisionEffectAuditOnly:
|
|
verb = "is audit-only for"
|
|
case api.DecisionEffectNotApplicable:
|
|
verb = "has no applicable policy for"
|
|
}
|
|
|
|
reason := decision.Reason
|
|
if reason == "" {
|
|
reason = string(decision.Effect)
|
|
}
|
|
return fmt.Sprintf("%s %s %s %sresource %s because %s.", actor, verb, capability, plane, decision.Resource.ID, reason)
|
|
}
|
|
|
|
func isEmptyAttribute(value any) bool {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed == ""
|
|
case api.OrganizationRelation:
|
|
return typed == ""
|
|
case []api.CanonicalRole:
|
|
return len(typed) == 0
|
|
case []string:
|
|
return len(typed) == 0
|
|
default:
|
|
return value == nil
|
|
}
|
|
}
|