generated from coulomb/repo-seed
Implement list allowed and explain
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -125,13 +126,77 @@ func TestBatchCheckPreservesResourceOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllowedReturnsOnlyAllowedResources(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.ImportResourceManifest(api.ResourceManifest{
|
||||
ID: "markitect-extra-documents",
|
||||
System: "markitect-tool",
|
||||
Resources: []api.Resource{
|
||||
{ID: "document:public-note", Type: "document", TrustZone: "public"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportResourceManifest: %v", err)
|
||||
}
|
||||
engine := newTestEngineWithStore(t, store)
|
||||
|
||||
got, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{
|
||||
Subject: api.SubjectRef{ID: "user:alice"},
|
||||
Action: "read",
|
||||
System: "markitect-tool",
|
||||
Filters: map[string]any{
|
||||
"resource_type": "document",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllowed: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(got) = %d; want one allowed resource: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Resource.ID != "document:internal-note" || got[0].Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("allowed decision = %+v; want document:internal-note allow", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainUsesRecordedDecision(t *testing.T) {
|
||||
engine := newTestEngine(t)
|
||||
|
||||
var request api.CheckRequest
|
||||
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
|
||||
|
||||
decisionEnvelope, err := engine.Check(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
explanation, err := engine.Explain(decisionEnvelope.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Explain: %v", err)
|
||||
}
|
||||
|
||||
if explanation.DecisionID != decisionEnvelope.ID {
|
||||
t.Fatalf("explanation.DecisionID = %q; want %q", explanation.DecisionID, decisionEnvelope.ID)
|
||||
}
|
||||
if explanation.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("explanation.Effect = %q; want allow", explanation.Effect)
|
||||
}
|
||||
if !strings.Contains(explanation.Summary, "Customer Doer may View Data Plane resource document:internal-note") {
|
||||
t.Fatalf("explanation.Summary = %q", explanation.Summary)
|
||||
}
|
||||
if _, err := engine.Explain("decision:missing"); err == nil {
|
||||
t.Fatal("Explain accepted unknown decision id")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEngine(t *testing.T) *decision.Engine {
|
||||
t.Helper()
|
||||
|
||||
store, err := registry.LoadFile(filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile registry: %v", err)
|
||||
}
|
||||
return newTestEngineWithStore(t, newTestStore(t))
|
||||
}
|
||||
|
||||
func newTestEngineWithStore(t *testing.T, store *registry.Store) *decision.Engine {
|
||||
t.Helper()
|
||||
|
||||
policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAndValidateFile policy: %v", err)
|
||||
@@ -143,6 +208,16 @@ func newTestEngine(t *testing.T) *decision.Engine {
|
||||
return engine
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *registry.Store {
|
||||
t.Helper()
|
||||
|
||||
store, err := registry.LoadFile(filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile registry: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func loadYAML(t *testing.T, path string, out any) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -246,6 +246,36 @@ func (s *Store) Resource(system, id string) (api.Resource, bool) {
|
||||
return resource, ok
|
||||
}
|
||||
|
||||
// ResourceRefs returns deterministic resource references filtered by system and
|
||||
// resource type. Empty filters match all values.
|
||||
func (s *Store) ResourceRefs(system, resourceType string) []api.ResourceRef {
|
||||
keys := make([]string, 0, len(s.resources))
|
||||
for key, resource := range s.resources {
|
||||
resourceSystem, _ := splitResourceKey(key)
|
||||
if system != "" && resourceSystem != system {
|
||||
continue
|
||||
}
|
||||
if resourceType != "" && resource.Type != resourceType {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
refs := make([]api.ResourceRef, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
resourceSystem, _ := splitResourceKey(key)
|
||||
resource := s.resources[key]
|
||||
refs = append(refs, api.ResourceRef{
|
||||
ID: resource.ID,
|
||||
Type: resource.Type,
|
||||
System: resourceSystem,
|
||||
Attributes: resourceRefAttributes(resource),
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// Subject looks up a subject by id.
|
||||
func (s *Store) Subject(id string) (api.Subject, bool) {
|
||||
subject, ok := s.subjects[id]
|
||||
@@ -299,6 +329,38 @@ func resourceKey(system, id string) string {
|
||||
return system + "\x00" + id
|
||||
}
|
||||
|
||||
func splitResourceKey(key string) (string, string) {
|
||||
for i := range key {
|
||||
if key[i] == '\x00' {
|
||||
return key[:i], key[i+1:]
|
||||
}
|
||||
}
|
||||
return "", key
|
||||
}
|
||||
|
||||
func resourceRefAttributes(resource api.Resource) map[string]any {
|
||||
attrs := make(map[string]any, len(resource.Attributes)+5)
|
||||
for key, value := range resource.Attributes {
|
||||
attrs[key] = value
|
||||
}
|
||||
if resource.Path != "" {
|
||||
attrs["path"] = resource.Path
|
||||
}
|
||||
if resource.Parent != "" {
|
||||
attrs["parent"] = resource.Parent
|
||||
}
|
||||
if len(resource.Labels) > 0 {
|
||||
attrs["labels"] = resource.Labels
|
||||
}
|
||||
if resource.TrustZone != "" {
|
||||
attrs["trust_zone"] = resource.TrustZone
|
||||
}
|
||||
if resource.Owner != "" {
|
||||
attrs["owner"] = resource.Owner
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func sortedValues[T any](items map[string]T) []T {
|
||||
keys := make([]string, 0, len(items))
|
||||
for key := range items {
|
||||
|
||||
@@ -173,7 +173,7 @@ conformance findings.
|
||||
|
||||
```task
|
||||
id: FLEX-WP-0002-T005
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e8fcbabd-4eb6-41d2-a4d5-6f40cc245a7e"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user