diff --git a/internal/decision/doc.go b/internal/decision/doc.go index bba68f3..fd74600 100644 --- a/internal/decision/doc.go +++ b/internal/decision/doc.go @@ -1,10 +1,10 @@ -// Package decision implements check, batch_check, list_allowed, and -// explain on top of the registry and policy packages. +// Package decision implements check, batch_check, list_allowed, and explain on +// top of the registry and policy packages. // // Decision envelopes carry effect, reason, matched policy version, // matched rule, resource metadata, subject metadata, obligations, // diagnostics, and provenance. Envelopes are identical for local and // delegated evaluation per ADR-003. // -// Implementation lands in FLEX-WP-0002 P2.4 and P2.5. +// list_allowed and explain land in FLEX-WP-0002 P2.5. package decision diff --git a/internal/decision/engine.go b/internal/decision/engine.go new file mode 100644 index 0000000..70f1174 --- /dev/null +++ b/internal/decision/engine.go @@ -0,0 +1,283 @@ +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 + } +} diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go new file mode 100644 index 0000000..366c66a --- /dev/null +++ b/internal/decision/engine_test.go @@ -0,0 +1,156 @@ +package decision_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/netkingdom/flex-auth/internal/decision" + "github.com/netkingdom/flex-auth/internal/policy" + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestCheckUsesExplicitCaringContext(t *testing.T) { + engine := newTestEngine(t) + + var request api.CheckRequest + loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request) + + got, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatalf("Check: %v", err) + } + again, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatalf("Check again: %v", err) + } + + if got.ID != again.ID { + t.Fatalf("decision id is not deterministic: %q != %q", got.ID, again.ID) + } + if got.Effect != api.DecisionEffectAllow { + t.Fatalf("got.Effect = %q; want allow", got.Effect) + } + if got.Reason != "reader_relation" { + t.Errorf("got.Reason = %q; want reader_relation", got.Reason) + } + if got.MatchedPolicyVersion != "v1" { + t.Errorf("got.MatchedPolicyVersion = %q; want v1", got.MatchedPolicyVersion) + } + if got.Subject.Type != api.SubjectTypeHuman || got.Subject.Attributes["groups"] == nil { + t.Errorf("got.Subject = %+v; want enriched human subject with groups", got.Subject) + } + if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" { + t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource) + } + if got.Caring == nil || got.Caring.Descriptor == nil { + t.Fatal("got.Caring.Descriptor is nil") + } + if got.Caring.Descriptor.ID != "descriptor:tenant-alpha-document-reader" { + t.Errorf("got.Caring.Descriptor.ID = %q", got.Caring.Descriptor.ID) + } + if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked { + t.Errorf("got.Caring.RestrictionsEvaluated = %v; want [ExportBlocked]", got.Caring.RestrictionsEvaluated) + } +} + +func TestCheckMatchesRegistryRelationshipDescriptor(t *testing.T) { + engine := newTestEngine(t) + + got, err := engine.Check(context.Background(), api.CheckRequest{ + ID: "check:registry-descriptor", + Subject: api.SubjectRef{ + ID: "user:alice", + }, + Action: "read", + Resource: api.ResourceRef{ + ID: "document:internal-note", + System: "markitect-tool", + }, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + + if got.Effect != api.DecisionEffectAllow { + t.Fatalf("got.Effect = %q; want allow", got.Effect) + } + if got.Caring == nil || got.Caring.Descriptor == nil { + t.Fatal("got.Caring.Descriptor is nil") + } + if got.Caring.Descriptor.SubjectType != api.SubjectTypeGroup { + t.Errorf("got.Caring.Descriptor.SubjectType = %q; want Group", got.Caring.Descriptor.SubjectType) + } + if got.Diagnostics["matched_relationship"] != "rel:alice-reader-internal-note" { + t.Errorf("matched_relationship = %v", got.Diagnostics["matched_relationship"]) + } +} + +func TestBatchCheckPreservesResourceOrder(t *testing.T) { + engine := newTestEngine(t) + + got, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{ + ID: "batch:read-documents", + Subject: api.SubjectRef{ + ID: "user:alice", + }, + Action: "read", + Resources: []api.ResourceRef{ + {ID: "document:internal-note", System: "markitect-tool"}, + {ID: "document:missing", Type: "document", System: "markitect-tool"}, + }, + }) + if err != nil { + t.Fatalf("BatchCheck: %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(got) = %d; want 2", len(got)) + } + if got[0].Resource.ID != "document:internal-note" || got[0].Effect != api.DecisionEffectAllow { + t.Fatalf("first decision = %+v; want allow for document:internal-note", got[0]) + } + if got[1].Resource.ID != "document:missing" || got[1].Effect != api.DecisionEffectDeny { + t.Fatalf("second decision = %+v; want deny for document:missing", got[1]) + } + if got[0].ID == got[1].ID { + t.Fatalf("batch decisions have duplicate deterministic ids: %q", got[0].ID) + } + if got[1].Caring == nil || len(got[1].Caring.ConformanceFindings) == 0 { + t.Fatal("missing descriptor deny should carry a CARING conformance finding") + } +} + +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) + } + policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md")) + if err != nil { + t.Fatalf("LoadAndValidateFile policy: %v", err) + } + engine, err := decision.NewEngine(store, policyPackage) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return engine +} + +func loadYAML(t *testing.T, path string, out any) { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if err := yaml.Unmarshal(data, out); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } +} diff --git a/internal/policy/package.go b/internal/policy/package.go index 0311435..4c9a3cd 100644 --- a/internal/policy/package.go +++ b/internal/policy/package.go @@ -144,6 +144,11 @@ func LoadAndValidateFile(ctx context.Context, path string) (*Package, error) { return pkg, nil } +// Evaluate runs the package decision entrypoint for a normalized check request. +func (p *Package) Evaluate(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) { + return p.evaluateDecision(ctx, request) +} + // Validate runs metadata, CARING, OPA parse/test, and fixture validation. func (p *Package) Validate(ctx context.Context) ValidationResult { result := ValidationResult{} 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 c853afa..db2a199 100644 --- a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md +++ b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md @@ -152,7 +152,7 @@ redact-with-obligation). ```task id: FLEX-WP-0002-T004 -status: todo +status: done priority: high state_hub_task_id: "f6427575-00af-4f3e-ab30-5b9a158343ef" ```