package decision import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "reflect" "slices" "sort" "strings" "sync" "time" "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 clock func() time.Time } // 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 } // SetClock overrides the engine clock. Tests use this to pin allow lifetimes. func (e *Engine) SetClock(clock func() time.Time) { e.mu.Lock() defer e.mu.Unlock() e.clock = clock } func (e *Engine) now() time.Time { e.mu.RLock() clock := e.clock e.mu.RUnlock() if clock != nil { return clock().UTC() } return time.Now().UTC() } // 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, request, 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, Tenant: request.Tenant, 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 // overriddenByRegistry names request attribute keys whose caller-supplied // value was displaced by a registry fact. Empty on an honest request; a // non-empty list means the caller asserted something the registry contradicts, // which is worth seeing even though the registry won (FLEX-DEC-2026-012). overriddenByRegistry []string } func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, registryFacts) { normalized := request facts := registryFacts{} overridden := []string{} if normalized.Tenant != "" { if normalized.Subject.Tenant == "" { normalized.Subject.Tenant = normalized.Tenant } if normalized.Resource.Tenant == "" { normalized.Resource.Tenant = normalized.Tenant } } if subject, ok := e.store.Subject(request.Subject.ID); ok { facts.subjectFound = true facts.subject = subject normalized.Subject = enrichSubjectRef(request.Subject, subject, &overridden) } 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, &overridden) } if normalized.CaringContext != nil { descriptor := *normalized.CaringContext facts.descriptor = &descriptor facts.overriddenByRegistry = overridden return normalized, facts } facts.overriddenByRegistry = overridden 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, "" } // enrichSubjectRef overlays registry facts onto the subject the caller sent. // // Registry facts WIN. A value in the registry is an authority statement; the // same key on the request is the caller's proposal about itself. Letting the // proposal survive made a registry ceiling advisory — see FLEX-DEC-2026-012, // where a caller supplying resource.attributes.allowed_subjects authorized a // subject the registry did not know. // // Keys the registry does not define still pass through from the request. That // residual is stated in docs/request-enrichment.md rather than fixed here: a // policy reading a key its manifest omits is consuming caller input, and the // structural fix is separating the two namespaces (FLEX-WP-0025). func enrichSubjectRef(ref api.SubjectRef, subject api.Subject, overridden *[]string) api.SubjectRef { out := ref // subject.Type is deliberately NOT overwritten, unlike every other registry // fact here. The registry's Type is CARING vocabulary (Human, Agent, // Automation, Service); the request's is the protected system's actor // vocabulary (service, adm, agt, atm). They are two different fields sharing // a name, and substituting one for the other is translation rather than // identity — the error GH-DEC-2026-008 ruled against. Making the registry win // here denied every secrets-engine allow, because "Service" is not "service". // // The residual is that a caller can still assert its own subject.type and // packages branch on it. That is not fixable by overwriting from a different // vocabulary; it needs the two fields separated. FLEX-WP-0025. if out.Type == "" { out.Type = subject.Type } if subject.Tenant != "" { out.Tenant = subject.Tenant } out.Attributes = copyMap(out.Attributes) setAuthoritative(out.Attributes, "display_name", subject.DisplayName, "subject", overridden) setAuthoritative(out.Attributes, "organization_relation", subject.OrganizationRelation, "subject", overridden) setAuthoritative(out.Attributes, "roles", subject.Roles, "subject", overridden) setAuthoritative(out.Attributes, "groups", subject.Groups, "subject", overridden) setAuthoritatives(out.Attributes, subject.Claims, "subject", overridden) setAuthoritatives(out.Attributes, subject.Metadata, "subject", overridden) return out } // enrichResourceRef overlays registry facts onto the resource the caller sent. // Registry facts win, for the reason given on enrichSubjectRef: ops-warden's // max_ttl_hours, allowed_subjects, and allowed_principals are ceilings and // allowlists, and a ceiling a caller can raise is not a ceiling. func enrichResourceRef(ref api.ResourceRef, resource api.Resource, overridden *[]string) api.ResourceRef { out := ref if resource.Type != "" { out.Type = resource.Type } out.Attributes = copyMap(out.Attributes) setAuthoritative(out.Attributes, "path", resource.Path, "resource", overridden) setAuthoritative(out.Attributes, "parent", resource.Parent, "resource", overridden) setAuthoritative(out.Attributes, "labels", resource.Labels, "resource", overridden) setAuthoritative(out.Attributes, "trust_zone", resource.TrustZone, "resource", overridden) setAuthoritative(out.Attributes, "owner", resource.Owner, "resource", overridden) setAuthoritatives(out.Attributes, resource.Attributes, "resource", overridden) return out } func (e *Engine) envelope(request, submitted 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, Binding: api.NewDecisionBindingFor(request, submitted), 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, "registry_overrode": facts.overriddenByRegistry, }, Provenance: api.DecisionProvenance{ Evaluator: "flex-auth/local", Mode: "standalone", PolicyPackage: e.policy.Metadata.ID, PolicyVersion: e.policy.Metadata.Version, PolicyPackageDigest: e.policy.Digest(), RegistrySnapshotDigest: e.store.Digest(), }, Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings), } api.CompleteDecision(&envelope, request, api.DecisionCompletion{ AllowTTL: e.policy.Metadata.AllowTTL, Now: e.now(), }) 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 } // setAuthoritative writes a registry fact over whatever the request carried for // that key, and records the key when a caller value was displaced so the // override is visible in diagnostics rather than silent. func setAuthoritative(target map[string]any, key string, value any, scope string, overridden *[]string) { if isEmptyAttribute(value) { return } if existing, exists := target[key]; exists && overridden != nil && !equalAttribute(existing, value) { *overridden = append(*overridden, scope+"."+key) } target[key] = value } func setAuthoritatives(target map[string]any, attrs map[string]any, scope string, overridden *[]string) { for _, key := range sortedKeys(attrs) { setAuthoritative(target, key, attrs[key], scope, overridden) } } func equalAttribute(a, b any) bool { left, errLeft := json.Marshal(a) right, errRight := json.Marshal(b) if errLeft != nil || errRight != nil { return false } return bytes.Equal(left, right) } func sortedKeys(attrs map[string]any) []string { keys := make([]string, 0, len(attrs)) for key := range attrs { keys = append(keys, key) } sort.Strings(keys) return keys } 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 } }