Implement list allowed and explain
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 05:45:36 +02:00
parent aa70dbebe1
commit faea068721
4 changed files with 309 additions and 9 deletions

View File

@@ -6,7 +6,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"sync"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
@@ -16,8 +19,36 @@ import (
// Engine evaluates deterministic standalone authorization checks against a
// local registry and one validated policy package.
type Engine struct {
store *registry.Store
policy *policy.Package
store *registry.Store
policy *policy.Package
mu sync.RWMutex
history map[string]api.DecisionEnvelope
}
// 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.
@@ -31,7 +62,11 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e
if !policyPackage.Valid {
return nil, fmt.Errorf("policy package %q is not valid", policyPackage.Metadata.ID)
}
return &Engine{store: store, policy: policyPackage}, nil
return &Engine{
store: store,
policy: policyPackage,
history: make(map[string]api.DecisionEnvelope),
}, nil
}
// Check evaluates one subject/action/resource request.
@@ -43,7 +78,9 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
return api.DecisionEnvelope{}, err
}
return e.envelope(normalized, expectation, facts), nil
decision := e.envelope(normalized, expectation, facts)
e.recordDecision(decision)
return decision, nil
}
// BatchCheck evaluates one subject/action/context tuple against resources in
@@ -67,6 +104,56 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest)
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
@@ -180,6 +267,7 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
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,
@@ -198,6 +286,12 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
return envelope
}
func (e *Engine) recordDecision(decision api.DecisionEnvelope) {
e.mu.Lock()
defer e.mu.Unlock()
e.history[decision.ID] = decision
}
func (e *Engine) caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
profile := e.policy.Metadata.Caring.Profile
if descriptor != nil && descriptor.Profile != "" {
@@ -267,6 +361,75 @@ func addAttribute(target map[string]any, key string, value any) {
}
}
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: