diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 70f1174..865e2f3 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -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: diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index 366c66a..e7b44aa 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -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() diff --git a/internal/registry/store.go b/internal/registry/store.go index 6b68516..979ff50 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -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 { diff --git a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md index db2a199..0f40e8f 100644 --- a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md +++ b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md @@ -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" ```