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 ExplainEnvelope(decision), nil } // ExplainEnvelope returns the same explanation shape for an already-loaded // decision envelope. func ExplainEnvelope(decision api.DecisionEnvelope) Explanation { 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, } } 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 } }