Files
flex-auth/internal/decision/engine.go
tegwick 54984585e3
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Implement deterministic check APIs
2026-05-17 05:38:57 +02:00

284 lines
8.8 KiB
Go

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
}
}