// Copyright (c) 2025-2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package policy import ( "testing" ) func TestEvaluator_Evaluate_AllowDecision(t *testing.T) { evaluator := NewEvaluator() policy := NewPolicy( "test", "Test Policy", Allow("iam:identity:get", "iam:identity:update"), ) tests := []struct { name string action string want Decision }{ { name: "allowed action - get", action: "iam:identity:get", want: DecisionAllow, }, { name: "allowed action - update", action: "iam:identity:update", want: DecisionAllow, }, { name: "not allowed action", action: "iam:identity:delete", want: DecisionNoMatch, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := AuthorizationRequest{ Action: tt.action, ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.Decision != tt.want { t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) } }) } } func TestEvaluator_Evaluate_DenyDecision(t *testing.T) { evaluator := NewEvaluator() policy := NewPolicy( "test", "Test Policy", Allow("iam:*:*"), Deny("iam:organization:delete").WithSID("deny-org-delete"), ) tests := []struct { name string action string want Decision }{ { name: "allowed by wildcard", action: "iam:identity:get", want: DecisionAllow, }, { name: "denied explicitly", action: "iam:organization:delete", want: DecisionDeny, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := AuthorizationRequest{ Action: tt.action, ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "org_789"}, }, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.Decision != tt.want { t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) } }) } } func TestEvaluator_Evaluate_DenyWinsOverAllow(t *testing.T) { evaluator := NewEvaluator() // Two policies: one allows, one denies the same action allowPolicy := NewPolicy( "allow", "Allow Policy", Allow("iam:organization:delete"), ) denyPolicy := NewPolicy( "deny", "Deny Policy", Deny("iam:organization:delete"), ) req := AuthorizationRequest{ Action: "iam:organization:delete", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "org_789"}, }, } // Deny should win regardless of order t.Run("deny first", func(t *testing.T) { result := evaluator.Evaluate(req, []*Policy{denyPolicy, allowPolicy}) if result.Decision != DecisionDeny { t.Errorf("Expected Deny, got %v", result.Decision) } }) t.Run("allow first", func(t *testing.T) { result := evaluator.Evaluate(req, []*Policy{allowPolicy, denyPolicy}) if result.Decision != DecisionDeny { t.Errorf("Expected Deny, got %v", result.Decision) } }) } func TestEvaluator_Evaluate_WithConditions(t *testing.T) { evaluator := NewEvaluator() // Policy that only allows users to update their own identity selfManagePolicy := NewPolicy( "self-manage", "Self Manage", Allow("iam:identity:update"). When(Equals("principal.id", "resource.id")), ) tests := []struct { name string principalID string resourceID string want Decision }{ { name: "condition satisfied - same user", principalID: "user_123", resourceID: "user_123", want: DecisionAllow, }, { name: "condition not satisfied - different user", principalID: "user_123", resourceID: "user_456", want: DecisionNoMatch, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := AuthorizationRequest{ Action: "iam:identity:update", ConditionContext: ConditionContext{ Principal: map[string]string{"id": tt.principalID}, Resource: map[string]string{"id": tt.resourceID}, }, } result := evaluator.Evaluate(req, []*Policy{selfManagePolicy}) if result.Decision != tt.want { t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) } }) } } func TestEvaluator_Evaluate_MultipleConditions(t *testing.T) { evaluator := NewEvaluator() // Policy that requires both conditions to be met policy := NewPolicy( "test", "Test", Allow("documents:document:update"). When( Equals("principal.id", "resource.owner_id"), Equals("resource.status", "draft"), ), ) tests := []struct { name string ctx ConditionContext want Decision }{ { name: "both conditions satisfied", ctx: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"owner_id": "user_123", "status": "draft"}, }, want: DecisionAllow, }, { name: "first condition not satisfied", ctx: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"owner_id": "user_456", "status": "draft"}, }, want: DecisionNoMatch, }, { name: "second condition not satisfied", ctx: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"owner_id": "user_123", "status": "published"}, }, want: DecisionNoMatch, }, { name: "neither condition satisfied", ctx: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"owner_id": "user_456", "status": "published"}, }, want: DecisionNoMatch, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := AuthorizationRequest{ Action: "documents:document:update", ConditionContext: tt.ctx, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.Decision != tt.want { t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) } }) } } func TestEvaluator_Evaluate_WildcardActions(t *testing.T) { evaluator := NewEvaluator() tests := []struct { name string policy *Policy action string want Decision }{ { name: "full wildcard allows everything", policy: NewPolicy("test", "Test", Allow("*")), action: "any:action:here", want: DecisionAllow, }, { name: "service wildcard", policy: NewPolicy("test", "Test", Allow("iam:*:*")), action: "iam:identity:get", want: DecisionAllow, }, { name: "service wildcard no match", policy: NewPolicy("test", "Test", Allow("iam:*:*")), action: "documents:document:read", want: DecisionNoMatch, }, { name: "operation wildcard", policy: NewPolicy("test", "Test", Allow("iam:identity:*")), action: "iam:identity:delete", want: DecisionAllow, }, { name: "read operations only", policy: NewPolicy("test", "Test", Allow("*:*:get", "*:*:list")), action: "iam:identity:get", want: DecisionAllow, }, { name: "read operations only - write denied", policy: NewPolicy("test", "Test", Allow("*:*:get", "*:*:list")), action: "iam:identity:update", want: DecisionNoMatch, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := AuthorizationRequest{ Action: tt.action, ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{tt.policy}) if result.Decision != tt.want { t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) } }) } } func TestEvaluator_Evaluate_EmptyPolicies(t *testing.T) { evaluator := NewEvaluator() req := AuthorizationRequest{ Action: "iam:identity:get", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{}) if result.Decision != DecisionNoMatch { t.Errorf("Expected NoMatch for empty policies, got %v", result.Decision) } } func TestEvaluator_Evaluate_NilPolicies(t *testing.T) { evaluator := NewEvaluator() req := AuthorizationRequest{ Action: "iam:identity:get", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, nil) if result.Decision != DecisionNoMatch { t.Errorf("Expected NoMatch for nil policies, got %v", result.Decision) } } func TestEvaluator_Evaluate_MatchedStatementAndPolicy(t *testing.T) { evaluator := NewEvaluator() policy := NewPolicy( "test-policy", "Test Policy", Allow("iam:identity:get").WithSID("allow-get"), Deny("iam:identity:delete").WithSID("deny-delete"), ) t.Run("matched allow statement", func(t *testing.T) { req := AuthorizationRequest{ Action: "iam:identity:get", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.MatchedStatement == nil { t.Fatal("Expected matched statement") } if result.MatchedStatement.SID != "allow-get" { t.Errorf("Expected SID 'allow-get', got %q", result.MatchedStatement.SID) } if result.MatchedPolicy == nil { t.Fatal("Expected matched policy") } if result.MatchedPolicy.ID != "test-policy" { t.Errorf("Expected policy ID 'test-policy', got %q", result.MatchedPolicy.ID) } }) t.Run("matched deny statement", func(t *testing.T) { req := AuthorizationRequest{ Action: "iam:identity:delete", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.MatchedStatement == nil { t.Fatal("Expected matched statement") } if result.MatchedStatement.SID != "deny-delete" { t.Errorf("Expected SID 'deny-delete', got %q", result.MatchedStatement.SID) } }) t.Run("no match - no statement or policy", func(t *testing.T) { req := AuthorizationRequest{ Action: "iam:identity:update", ConditionContext: ConditionContext{ Principal: map[string]string{"id": "user_123"}, Resource: map[string]string{"id": "res_456"}, }, } result := evaluator.Evaluate(req, []*Policy{policy}) if result.MatchedStatement != nil { t.Error("Expected no matched statement") } if result.MatchedPolicy != nil { t.Error("Expected no matched policy") } }) } func TestEvaluationResult_IsAllowed(t *testing.T) { tests := []struct { decision Decision want bool }{ {DecisionAllow, true}, {DecisionDeny, false}, {DecisionNoMatch, false}, } for _, tt := range tests { t.Run(string(tt.decision), func(t *testing.T) { result := EvaluationResult{Decision: tt.decision} if result.IsAllowed() != tt.want { t.Errorf("IsAllowed() = %v, want %v", result.IsAllowed(), tt.want) } }) } } func TestEvaluationResult_PolicyID(t *testing.T) { t.Parallel() result := EvaluationResult{ Decision: DecisionAllow, MatchedStatement: &Statement{ SID: "read-thirdParties", }, MatchedPolicy: &Policy{ ID: "probo:viewer", }, } if got := result.PolicyID(); got != "read-thirdParties" { t.Errorf("PolicyID() = %q, want read-thirdParties", got) } } func TestEvaluationResult_Reason(t *testing.T) { t.Parallel() t.Run("implicit deny includes role", func(t *testing.T) { t.Parallel() result := EvaluationResult{Decision: DecisionNoMatch} want := "implicit deny: no matching allow for role VIEWER" if got := result.Reason("VIEWER"); got != want { t.Errorf("Reason() = %q, want %q", got, want) } }) t.Run("explicit deny uses statement sid", func(t *testing.T) { t.Parallel() result := EvaluationResult{ Decision: DecisionDeny, MatchedStatement: &Statement{ SID: "deny-delete", }, } want := "explicit deny by statement deny-delete" if got := result.Reason("ADMIN"); got != want { t.Errorf("Reason() = %q, want %q", got, want) } }) }