diff --git a/pkg/iam/policy/matcher_test.go b/pkg/iam/policy/matcher_test.go index 8412eb79f..bfbd4a40e 100644 --- a/pkg/iam/policy/matcher_test.go +++ b/pkg/iam/policy/matcher_test.go @@ -166,8 +166,26 @@ func TestActionMatcher_Matches(t *testing.T) { target: "documents:document:read", want: false, }, + { + name: "two-part pattern without wildcard is invalid", + pattern: "iam:identity", + target: "iam:identity:get", + want: false, + }, // Invalid targets + { + name: "single-part non-wildcard pattern is invalid", + pattern: "iam", + target: "iam:identity:get", + want: false, + }, + { + name: "pattern with too many parts is invalid", + pattern: "iam:identity:get:extra", + target: "iam:identity:get", + want: false, + }, { name: "invalid target - too few parts", pattern: "iam:identity:get", diff --git a/pkg/iam/policy/policy_test.go b/pkg/iam/policy/policy_test.go new file mode 100644 index 000000000..282fb7504 --- /dev/null +++ b/pkg/iam/policy/policy_test.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "testing" + + "go.probo.inc/probo/pkg/gid" +) + +func TestPolicy_AddStatement(t *testing.T) { + p := NewPolicy("test", "Test") + + stmt := Allow("iam:identity:get").WithSID("allow-identity-get") + p.AddStatement(stmt) + + if len(p.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(p.Statements)) + } + + if p.Statements[0].SID != "allow-identity-get" { + t.Errorf("expected SID %q, got %q", "allow-identity-get", p.Statements[0].SID) + } +} + +func TestStatement_WithResources(t *testing.T) { + tenantID := gid.NewTenantID() + entityType := uint16(1001) + + stmt := Allow("core:framework:get").WithResources( + ResourcePattern{ + TenantID: &tenantID, + EntityType: &entityType, + }, + ) + + if len(stmt.Resources) != 1 { + t.Fatalf("expected 1 resource pattern, got %d", len(stmt.Resources)) + } + + if stmt.Resources[0].TenantID == nil || *stmt.Resources[0].TenantID != tenantID { + t.Fatalf("expected tenant id %q in resource pattern", tenantID) + } + + if stmt.Resources[0].EntityType == nil || *stmt.Resources[0].EntityType != entityType { + t.Fatalf("expected entity type %d in resource pattern", entityType) + } +} + +func TestEvaluator_Evaluate_ResourcePatternFilters(t *testing.T) { + evaluator := NewEvaluator() + tenantID := gid.NewTenantID() + otherTenantID := gid.NewTenantID() + entityType := uint16(1001) + otherEntityType := uint16(1002) + + p := NewPolicy( + "resource-filter", + "Resource Filter", + Allow("core:framework:get").WithResources( + ResourcePattern{ + TenantID: &tenantID, + EntityType: &entityType, + }, + ), + ) + + tests := []struct { + name string + resource gid.GID + want Decision + }{ + { + name: "matching tenant and entity type allows", + resource: gid.New(tenantID, entityType), + want: DecisionAllow, + }, + { + name: "different tenant does not match", + resource: gid.New(otherTenantID, entityType), + want: DecisionNoMatch, + }, + { + name: "different entity type does not match", + resource: gid.New(tenantID, otherEntityType), + want: DecisionNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evaluator.Evaluate( + AuthorizationRequest{ + Action: "core:framework:get", + Resource: tt.resource, + }, + []*Policy{p}, + ) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} diff --git a/pkg/iam/policy/statement.go b/pkg/iam/policy/statement.go index 87ec5eeab..a35587810 100644 --- a/pkg/iam/policy/statement.go +++ b/pkg/iam/policy/statement.go @@ -104,18 +104,26 @@ const ( ConditionNotIn ConditionOperator = "NotIn" ) +type ( + // Attributes is a flat key/value bag consumed by policy condition + // evaluation (e.g. "organization_id", "role", "id"). + Attributes = map[string]string + + // AttributesByID groups Attributes by resource id, as returned by + // batch attribute loaders. + AttributesByID = map[gid.GID]Attributes +) + // ConditionContext provides attribute values for condition evaluation. type ConditionContext struct { - Principal map[string]string - Resource map[string]string + Principal Attributes + Resource Attributes } // Evaluate checks if the condition is satisfied given the context. func (c Condition) Evaluate(ctx ConditionContext) bool { - // Resolve the key value from context value, ok := resolveKey(c.Key, ctx) if !ok { - // Key not found - condition fails return false } @@ -198,7 +206,6 @@ func (c Condition) Evaluate(ctx ConditionContext) bool { // resolveKey extracts a value from the context based on a key path. // Key format: "principal.id", "resource.owner_id", etc. func resolveKey(key string, ctx ConditionContext) (string, bool) { - // Simple implementation - can be extended for nested paths if len(key) > 10 && key[:10] == "principal." { attrKey := key[10:] val, ok := ctx.Principal[attrKey] @@ -216,9 +223,9 @@ func resolveKey(key string, ctx ConditionContext) (string, bool) { return "", false } -// resolveValue resolves a value, which can be a literal or a reference to context. +// resolveValue returns either a context reference (e.g. "principal.id") +// resolved against ctx, or the value itself when it is a literal. func resolveValue(value string, ctx ConditionContext) (string, bool) { - // Check if value is a reference (e.g., "principal.id") if len(value) > 10 && value[:10] == "principal." { return resolveKey(value, ctx) } @@ -227,6 +234,5 @@ func resolveValue(value string, ctx ConditionContext) (string, bool) { return resolveKey(value, ctx) } - // Literal value return value, true } diff --git a/pkg/iam/policy/statement_test.go b/pkg/iam/policy/statement_test.go index cf65e990d..f86df8988 100644 --- a/pkg/iam/policy/statement_test.go +++ b/pkg/iam/policy/statement_test.go @@ -16,8 +16,93 @@ package policy import ( "testing" + + "go.probo.inc/probo/pkg/gid" ) +func TestResourcePattern_MatchesResource(t *testing.T) { + tenantID := gid.NewTenantID() + otherTenantID := gid.NewTenantID() + frameworkEntityType := uint16(1001) + organizationEntityType := uint16(1002) + resource := gid.New(tenantID, frameworkEntityType) + otherTenantResource := gid.New(otherTenantID, frameworkEntityType) + otherEntityResource := gid.New(tenantID, organizationEntityType) + + tests := []struct { + name string + pattern ResourcePattern + resource gid.GID + want bool + }{ + { + name: "empty pattern matches all resources", + pattern: ResourcePattern{}, + resource: resource, + want: true, + }, + { + name: "tenant only pattern matches same tenant", + pattern: ResourcePattern{ + TenantID: &tenantID, + }, + resource: resource, + want: true, + }, + { + name: "tenant only pattern does not match different tenant", + pattern: ResourcePattern{ + TenantID: &tenantID, + }, + resource: otherTenantResource, + want: false, + }, + { + name: "entity type only pattern matches same type", + pattern: ResourcePattern{ + EntityType: &frameworkEntityType, + }, + resource: resource, + want: true, + }, + { + name: "entity type only pattern does not match different type", + pattern: ResourcePattern{ + EntityType: &frameworkEntityType, + }, + resource: otherEntityResource, + want: false, + }, + { + name: "tenant and entity type pattern matches when both match", + pattern: ResourcePattern{ + TenantID: &tenantID, + EntityType: &frameworkEntityType, + }, + resource: resource, + want: true, + }, + { + name: "tenant and entity type pattern fails when one mismatches", + pattern: ResourcePattern{ + TenantID: &tenantID, + EntityType: &frameworkEntityType, + }, + resource: otherEntityResource, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.pattern.MatchesResource(tt.resource) + if got != tt.want { + t.Errorf("MatchesResource() = %v, want %v", got, tt.want) + } + }) + } +} + func TestCondition_Evaluate_Equals(t *testing.T) { tests := []struct { name string @@ -223,6 +308,45 @@ func TestCondition_Evaluate_In(t *testing.T) { }, want: false, }, + { + name: "in - matches value inside comma-separated set", + condition: Condition{ + Operator: ConditionIn, + Key: "principal.organization_id", + Values: []string{"resource.organization_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_2"}, + Resource: map[string]string{"organization_ids": "org_1, org_2, org_3"}, + }, + want: true, + }, + { + name: "in - does not match comma-separated set", + condition: Condition{ + Operator: ConditionIn, + Key: "principal.organization_id", + Values: []string{"resource.organization_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_9"}, + Resource: map[string]string{"organization_ids": "org_1,org_2"}, + }, + want: false, + }, + { + name: "in - skips unresolved references", + condition: Condition{ + Operator: ConditionIn, + Key: "principal.organization_id", + Values: []string{"resource.missing_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_2"}, + Resource: map[string]string{"organization_ids": "org_1,org_2"}, + }, + want: false, + }, } for _, tt := range tests { @@ -266,6 +390,57 @@ func TestCondition_Evaluate_NotIn(t *testing.T) { }, want: false, }, + { + name: "not in - comma-separated set contains value", + condition: Condition{ + Operator: ConditionNotIn, + Key: "principal.organization_id", + Values: []string{"resource.organization_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_2"}, + Resource: map[string]string{"organization_ids": "org_1, org_2, org_3"}, + }, + want: false, + }, + { + name: "not in - comma-separated set does not contain value", + condition: Condition{ + Operator: ConditionNotIn, + Key: "principal.organization_id", + Values: []string{"resource.organization_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_9"}, + Resource: map[string]string{"organization_ids": "org_1,org_2"}, + }, + want: true, + }, + { + name: "unknown operator returns false", + condition: Condition{ + Operator: ConditionOperator("Unknown"), + Key: "principal.role", + Values: []string{"admin"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"role": "admin"}, + }, + want: false, + }, + { + name: "not in - skips unresolved references", + condition: Condition{ + Operator: ConditionNotIn, + Key: "principal.organization_id", + Values: []string{"resource.missing_ids"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"organization_id": "org_9"}, + Resource: map[string]string{"organization_ids": "org_1,org_2"}, + }, + want: true, + }, } for _, tt := range tests { @@ -312,3 +487,103 @@ func TestConditionHelpers(t *testing.T) { } }) } + +func TestResolveKey(t *testing.T) { + ctx := ConditionContext{ + Principal: map[string]string{ + "id": "principal-id", + }, + Resource: map[string]string{ + "id": "resource-id", + }, + } + + tests := []struct { + name string + key string + want string + wantOK bool + }{ + { + name: "resolves principal key", + key: "principal.id", + want: "principal-id", + wantOK: true, + }, + { + name: "resolves resource key", + key: "resource.id", + want: "resource-id", + wantOK: true, + }, + { + name: "returns false for unknown namespace", + key: "unknown.id", + want: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := resolveKey(tt.key, ctx) + if ok != tt.wantOK { + t.Fatalf("resolveKey() ok = %v, want %v", ok, tt.wantOK) + } + + if got != tt.want { + t.Fatalf("resolveKey() value = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveValue(t *testing.T) { + ctx := ConditionContext{ + Principal: map[string]string{ + "id": "principal-id", + }, + Resource: map[string]string{ + "id": "resource-id", + }, + } + + tests := []struct { + name string + value string + want string + wantOK bool + }{ + { + name: "resolves principal reference", + value: "principal.id", + want: "principal-id", + wantOK: true, + }, + { + name: "resolves resource reference", + value: "resource.id", + want: "resource-id", + wantOK: true, + }, + { + name: "keeps literal values", + value: "literal", + want: "literal", + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := resolveValue(tt.value, ctx) + if ok != tt.wantOK { + t.Fatalf("resolveValue() ok = %v, want %v", ok, tt.wantOK) + } + + if got != tt.want { + t.Fatalf("resolveValue() value = %q, want %q", got, tt.want) + } + }) + } +}