package decision import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "slices" "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 } // 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}, nil } // 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 } return e.envelope(normalized, expectation, facts), 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 } 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{ "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) 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 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 } }