From 8814c2a07d1ccc2cd311f24c62af39f76856201e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 24 Jan 2026 13:34:49 -0800 Subject: [PATCH] iam: support ForAnyValue and ForAllValues condition set operators (#8105) * iam: support ForAnyValue and ForAllValues condition set operators This implementation adds support for AWS-style IAM condition set operators `ForAnyValue:` and `ForAllValues:`. These are essential for trust policies that evaluate collection-based claims like `oidc:roles` or groups. - Updated EvaluateStringCondition to handle set operators. - Added set operator support to numeric, date, and boolean conditions. - ForAnyValue matches if any request value matches any condition value (default). - ForAllValues matches if every request value matches at least one condition value. * iam: add test suite for condition set operators * iam: ensure ForAllValues is vacuously true for all condition types Aligned Numeric, Date, and Boolean conditions with AWS IAM behavior where ForAllValues returns true when the request context values are empty. * iam: add Date vacuously true test case for ForAllValues * iam: expand policy variables in case-insensitive string conditions Added expandPolicyVariables support to evaluateStringConditionIgnoreCase to ensure consistency with case-sensitive counterparts. * iam: fix negation issues in string set operators Refactored EvaluateStringCondition and evaluateStringConditionIgnoreCase to evaluate operators (including negation) per context value before aggregating. This ensures StringNotEquals and StringNotLike work correctly with ForAllValues and ForAnyValue. * iam: add []string support for Date and Boolean context values Ensures consistency with Numeric conditions by allowing context values to be provided as slices of strings, which is common in JSON/OIDC claims. * iam: simplify redundant type check in policy engine The `evaluateStringConditionIgnoreCase` function had a redundant type check for `string` in the `default` block of a type switch that already handled the `string` case. * iam: remove outdated "currently fails" comment in negation tests * iam: add StringLikeIgnoreCase condition support * iam: explicitly handle empty context sets for ForAnyValue AWS IAM treats empty request sets as "no match" for ForAnyValue. Added an explicit check and comment to make this behavior clear. * iam: refactor EvaluateStringCondition to expand policy variables once Avoid redundant calls to expandPolicyVariables by expanding them once per condition value instead of inside awsIAMMatch or in the exact matching branch. * iam: fix StringLike case sensitivity to match AWS IAM specs StringLike and StringNotLike condition operators are case-sensitive in AWS IAM. Changed the implementation to use filepath.Match for case-sensitive wildcard matching instead of the case-insensitive awsIAMMatch. * iam: integrate StringLike case-sensitivity test into suite Integrated the case-sensitivity verification into condition_set_test.go and updated the consistency test to use StringLikeIgnoreCase to maintain its case-insensitive matching verification. * iam: fix NumericNotEquals logic to follow "not equal to any" semantics Updated evaluateNumericCondition to correctly handle NumericNotEquals by ensuring a context value matches only if it is not equal to ANY of the provided expected values. Also added support for []string expected values. * iam: fix DateNotEquals logic and integrate tests Updated evaluateDateCondition to correctly handle DateNotEquals logic. Integrated the new test cases for NumericNotEquals and DateNotEquals into condition_set_test.go. * iam: fix validation error in integrated NotEquals tests Added missing Resource field to IAM policy statements in condition_set_test.go to satisfy validation requirements. * iam: add set operator support for IP and Null conditions Implemented ForAllValues and ForAnyValue support for IpAddress, NotIpAddress, and Null condition operators. Also added test coverage for ForAnyValue with an empty context to ensure correct behavior. * iam: refine IP condition evaluation to handle multiple policy value types Updated evaluateIPCondition to correctly handle string, []string, and []interface{} values for IP address conditions in policy documents. Added IpAddress:SingleStringValue test case to verify consistency. * iam: refine Null and case-insensitive string conditions - Reverted evaluateNullCondition to standard AWS behavior (no set operators). - Refactored evaluateStringConditionIgnoreCase to use idiomatic helpers (strings.EqualFold and AwsWildcardMatch). - Cleaned up tests in condition_set_test.go. * iam: normalize policy value handling across condition evaluators - Implemented normalizeRanges helper for consistent IP range extraction. - Expanded type switches in IP, Bool, and String condition evaluators to support string, []string, and []interface{} policy values. - Fixed ForAnyValue bool matching to support string slices. - Added targeted tests for []string policy values in condition_set_test.go. * iam: refactor IP condition to support arbitrary context keys Refactored evaluateIPCondition to iterate through all keys in the condition block instead of hardcoding aws:SourceIp. This ensures consistency with other condition types and allows custom context keys. Added IpAddress:CustomContextKey test case to verify the change. --- weed/iam/policy/condition_set_test.go | 687 +++++++++++++ weed/iam/policy/negation_test.go | 101 ++ weed/iam/policy/policy_engine.go | 914 ++++++++++++++---- .../policy/policy_variable_matching_test.go | 4 +- 4 files changed, 1517 insertions(+), 189 deletions(-) create mode 100644 weed/iam/policy/condition_set_test.go create mode 100644 weed/iam/policy/negation_test.go diff --git a/weed/iam/policy/condition_set_test.go b/weed/iam/policy/condition_set_test.go new file mode 100644 index 000000000..4c7e8bb67 --- /dev/null +++ b/weed/iam/policy/condition_set_test.go @@ -0,0 +1,687 @@ +package policy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConditionSetOperators(t *testing.T) { + engine := setupTestPolicyEngine(t) + + t.Run("ForAnyValue:StringEquals", func(t *testing.T) { + trustPolicy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowOIDC", + Effect: "Allow", + Action: []string{"sts:AssumeRoleWithWebIdentity"}, + Condition: map[string]map[string]interface{}{ + "ForAnyValue:StringEquals": { + "oidc:roles": []string{"Dev.SeaweedFS.TestBucket.ReadWrite", "Dev.SeaweedFS.Admin"}, + }, + }, + }, + }, + } + + // Match: Admin is in the requested roles + evalCtxMatch := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"Dev.SeaweedFS.Admin", "OtherRole"}, + }, + } + resultMatch, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicy, evalCtxMatch) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultMatch.Effect) + + // No Match + evalCtxNoMatch := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"OtherRole1", "OtherRole2"}, + }, + } + resultNoMatch, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicy, evalCtxNoMatch) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultNoMatch.Effect) + + // No Match: Empty context for ForAnyValue (should deny) + evalCtxEmpty := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{}, + }, + } + resultEmpty, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicy, evalCtxEmpty) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultEmpty.Effect, "ForAnyValue should deny when context is empty") + }) + + t.Run("ForAllValues:StringEquals", func(t *testing.T) { + trustPolicyAll := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowOIDCAll", + Effect: "Allow", + Action: []string{"sts:AssumeRoleWithWebIdentity"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:StringEquals": { + "oidc:roles": []string{"RoleA", "RoleB", "RoleC"}, + }, + }, + }, + }, + } + + // Match: All requested roles ARE in the allowed set + evalCtxAllMatch := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"RoleA", "RoleB"}, + }, + } + resultAllMatch, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicyAll, evalCtxAllMatch) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultAllMatch.Effect) + + // Fail: RoleD is NOT in the allowed set + evalCtxAllFail := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"RoleA", "RoleD"}, + }, + } + resultAllFail, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicyAll, evalCtxAllFail) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultAllFail.Effect) + + // Vacuously true: Request has NO roles + evalCtxEmpty := &EvaluationContext{ + Principal: "web-identity-user", + Action: "sts:AssumeRoleWithWebIdentity", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{}, + }, + } + resultEmpty, err := engine.EvaluateTrustPolicy(context.Background(), trustPolicyAll, evalCtxEmpty) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultEmpty.Effect) + }) + + t.Run("ForAllValues:NumericEqualsVacuouslyTrue", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowNumericAll", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:NumericEquals": { + "aws:MultiFactorAuthAge": []string{"3600", "7200"}, + }, + }, + }, + }, + } + + // Vacuously true: Request has NO MFA age info + evalCtxEmpty := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:MultiFactorAuthAge": []string{}, + }, + } + resultEmpty, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxEmpty) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultEmpty.Effect, "Should allow when numeric context is empty for ForAllValues") + }) + + t.Run("ForAllValues:BoolVacuouslyTrue", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowBoolAll", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:Bool": { + "aws:SecureTransport": "true", + }, + }, + }, + }, + } + + // Vacuously true + evalCtxEmpty := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:SecureTransport": []interface{}{}, + }, + } + resultEmpty, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxEmpty) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultEmpty.Effect, "Should allow when bool context is empty for ForAllValues") + }) + + t.Run("ForAllValues:DateVacuouslyTrue", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowDateAll", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:DateGreaterThan": { + "aws:CurrentTime": "2020-01-01T00:00:00Z", + }, + }, + }, + }, + } + + // Vacuously true + evalCtxEmpty := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:CurrentTime": []interface{}{}, + }, + } + resultEmpty, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxEmpty) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultEmpty.Effect, "Should allow when date context is empty for ForAllValues") + }) + + t.Run("ForAllValues:DateWithLabelsAsStrings", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowDateStrings", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:DateGreaterThan": { + "aws:CurrentTime": "2020-01-01T00:00:00Z", + }, + }, + }, + }, + } + + evalCtx := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:CurrentTime": []string{"2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + }, + } + result, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtx) + require.NoError(t, err) + assert.Equal(t, EffectAllow, result.Effect, "Should allow when date context is a slice of strings") + }) + + t.Run("ForAllValues:BoolWithLabelsAsStrings", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowBoolStrings", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:Bool": { + "aws:SecureTransport": "true", + }, + }, + }, + }, + } + + evalCtx := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:SecureTransport": []string{"true", "true"}, + }, + } + result, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtx) + require.NoError(t, err) + assert.Equal(t, EffectAllow, result.Effect, "Should allow when bool context is a slice of strings") + }) + + t.Run("StringEqualsIgnoreCaseWithVariable", func(t *testing.T) { + policyDoc := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowVar", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::bucket/*"}, + Condition: map[string]map[string]interface{}{ + "StringEqualsIgnoreCase": { + "s3:prefix": "${aws:username}/", + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "var-policy", policyDoc) + require.NoError(t, err) + + evalCtx := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/ALICE/file.txt", + RequestContext: map[string]interface{}{ + "s3:prefix": "ALICE/", + "aws:username": "alice", + }, + } + + result, err := engine.Evaluate(context.Background(), "", evalCtx, []string{"var-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, result.Effect, "Should allow when variable expands and matches case-insensitively") + }) + + t.Run("StringLike:CaseSensitivity", func(t *testing.T) { + policyDoc := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowCaseSensitiveLike", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::bucket/*"}, + Condition: map[string]map[string]interface{}{ + "StringLike": { + "s3:prefix": "Project/*", + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "like-policy", policyDoc) + require.NoError(t, err) + + // Match: Case sensitive match + evalCtxMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/Project/file.txt", + RequestContext: map[string]interface{}{ + "s3:prefix": "Project/data", + }, + } + resultMatch, err := engine.Evaluate(context.Background(), "", evalCtxMatch, []string{"like-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultMatch.Effect, "Should allow when case matches exactly") + + // Fail: Case insensitive match (should fail for StringLike) + evalCtxFail := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/project/file.txt", + RequestContext: map[string]interface{}{ + "s3:prefix": "project/data", // lowercase 'p' + }, + } + resultFail, err := engine.Evaluate(context.Background(), "", evalCtxFail, []string{"like-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultFail.Effect, "Should deny when case does not match for StringLike") + }) + + t.Run("NumericNotEquals:Logic", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "DenySpecificAges", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:NumericNotEquals": { + "aws:MultiFactorAuthAge": []string{"3600", "7200"}, + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "numeric-not-equals-policy", policy) + require.NoError(t, err) + + // Fail: One age matches an excluded value (3600) + evalCtxFail := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:MultiFactorAuthAge": []string{"3600", "1800"}, + }, + } + resultFail, err := engine.Evaluate(context.Background(), "", evalCtxFail, []string{"numeric-not-equals-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultFail.Effect, "Should deny when one age matches an excluded value") + + // Pass: No age matches any excluded value + evalCtxPass := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:MultiFactorAuthAge": []string{"1800", "900"}, + }, + } + resultPass, err := engine.Evaluate(context.Background(), "", evalCtxPass, []string{"numeric-not-equals-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultPass.Effect, "Should allow when no age matches excluded values") + }) + + t.Run("DateNotEquals:Logic", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "DenySpecificTimes", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:DateNotEquals": { + "aws:CurrentTime": []string{"2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"}, + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "date-not-equals-policy", policy) + require.NoError(t, err) + + // Fail: One time matches an excluded value + evalCtxFail := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "aws:CurrentTime": []string{"2024-01-01T00:00:00Z", "2024-01-03T00:00:00Z"}, + }, + } + resultFail, err := engine.Evaluate(context.Background(), "", evalCtxFail, []string{"date-not-equals-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultFail.Effect, "Should deny when one date matches an excluded value") + }) + + t.Run("IpAddress:SetOperators", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowSpecificIPs", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:IpAddress": { + "aws:SourceIp": []string{"192.168.1.0/24", "10.0.0.1"}, + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "ip-set-policy", policy) + require.NoError(t, err) + + // Match: All source IPs are in allowed ranges + evalCtxMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SourceIp": []string{"192.168.1.10", "10.0.0.1"}, + }, + } + resultMatch, err := engine.Evaluate(context.Background(), "", evalCtxMatch, []string{"ip-set-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultMatch.Effect) + + // Fail: One source IP is NOT in allowed ranges + evalCtxFail := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SourceIp": []string{"192.168.1.10", "172.16.0.1"}, + }, + } + resultFail, err := engine.Evaluate(context.Background(), "", evalCtxFail, []string{"ip-set-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultFail.Effect) + + // ForAnyValue: IPAddress + policyAny := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowAnySpecificIPs", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "ForAnyValue:IpAddress": { + "aws:SourceIp": []string{"192.168.1.0/24"}, + }, + }, + }, + }, + } + err = engine.AddPolicy("", "ip-any-policy", policyAny) + require.NoError(t, err) + + evalCtxAnyMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SourceIp": []string{"192.168.1.10", "172.16.0.1"}, + }, + } + resultAnyMatch, err := engine.Evaluate(context.Background(), "", evalCtxAnyMatch, []string{"ip-any-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultAnyMatch.Effect) + }) + + t.Run("IpAddress:SingleStringValue", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowSingleIP", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "IpAddress": { + "aws:SourceIp": "192.168.1.1", + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "ip-single-policy", policy) + require.NoError(t, err) + + evalCtxMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SourceIp": "192.168.1.1", + }, + } + resultMatch, err := engine.Evaluate(context.Background(), "", evalCtxMatch, []string{"ip-single-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultMatch.Effect) + + evalCtxNoMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SourceIp": "10.0.0.1", + }, + } + resultNoMatch, err := engine.Evaluate(context.Background(), "", evalCtxNoMatch, []string{"ip-single-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultNoMatch.Effect) + }) + + t.Run("Bool:StringSlicePolicyValues", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowWithBoolStrings", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "Bool": { + "aws:SecureTransport": []string{"true", "false"}, + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "bool-string-slice-policy", policy) + require.NoError(t, err) + + evalCtx := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "aws:SecureTransport": "true", + }, + } + result, err := engine.Evaluate(context.Background(), "", evalCtx, []string{"bool-string-slice-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, result.Effect) + }) + + t.Run("StringEqualsIgnoreCase:StringSlicePolicyValues", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowWithIgnoreCaseStrings", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "StringEqualsIgnoreCase": { + "s3:x-amz-server-side-encryption": []string{"AES256", "aws:kms"}, + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "string-ignorecase-slice-policy", policy) + require.NoError(t, err) + + evalCtx := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "s3:x-amz-server-side-encryption": "aes256", + }, + } + result, err := engine.Evaluate(context.Background(), "", evalCtx, []string{"string-ignorecase-slice-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, result.Effect) + }) + + t.Run("IpAddress:CustomContextKey", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "AllowCustomIPKey", + Effect: "Allow", + Action: []string{"s3:GetObject"}, + Resource: []string{"*"}, + Condition: map[string]map[string]interface{}{ + "IpAddress": { + "custom:VpcIp": "10.0.0.0/16", + }, + }, + }, + }, + } + + err := engine.AddPolicy("", "ip-custom-key-policy", policy) + require.NoError(t, err) + + evalCtxMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "custom:VpcIp": "10.0.5.1", + }, + } + resultMatch, err := engine.Evaluate(context.Background(), "", evalCtxMatch, []string{"ip-custom-key-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultMatch.Effect) + + evalCtxNoMatch := &EvaluationContext{ + Principal: "user", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::bucket/file.txt", + RequestContext: map[string]interface{}{ + "custom:VpcIp": "192.168.1.1", + }, + } + resultNoMatch, err := engine.Evaluate(context.Background(), "", evalCtxNoMatch, []string{"ip-custom-key-policy"}) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultNoMatch.Effect) + }) +} diff --git a/weed/iam/policy/negation_test.go b/weed/iam/policy/negation_test.go new file mode 100644 index 000000000..31eed396f --- /dev/null +++ b/weed/iam/policy/negation_test.go @@ -0,0 +1,101 @@ +package policy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNegationSetOperators(t *testing.T) { + engine := setupTestPolicyEngine(t) + + t.Run("ForAllValues:StringNotEquals", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "DenyAdmin", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAllValues:StringNotEquals": { + "oidc:roles": []string{"Admin"}, + }, + }, + }, + }, + } + + // All roles are NOT "Admin" -> Should Allow + evalCtxAllow := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"User", "Developer"}, + }, + } + resultAllow, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxAllow) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultAllow.Effect, "Should allow when ALL roles satisfy StringNotEquals Admin") + + // One role is "Admin" -> Should Deny + evalCtxDeny := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"Admin", "User"}, + }, + } + resultDeny, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxDeny) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultDeny.Effect, "Should deny when one role is Admin and fails StringNotEquals") + }) + + t.Run("ForAnyValue:StringNotEquals", func(t *testing.T) { + policy := &PolicyDocument{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "Requirement", + Effect: "Allow", + Action: []string{"sts:AssumeRole"}, + Condition: map[string]map[string]interface{}{ + "ForAnyValue:StringNotEquals": { + "oidc:roles": []string{"Prohibited"}, + }, + }, + }, + }, + } + + // At least one role is NOT prohibited -> Should Allow + evalCtxAllow := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"Prohibited", "Allowed"}, + }, + } + resultAllow, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxAllow) + require.NoError(t, err) + assert.Equal(t, EffectAllow, resultAllow.Effect, "Should allow when at least one role is NOT Prohibited") + + // All roles are Prohibited -> Should Deny + evalCtxDeny := &EvaluationContext{ + Principal: "user", + Action: "sts:AssumeRole", + Resource: "arn:aws:iam::role/test-role", + RequestContext: map[string]interface{}{ + "oidc:roles": []string{"Prohibited", "Prohibited"}, + }, + } + resultDeny, err := engine.EvaluateTrustPolicy(context.Background(), policy, evalCtxDeny) + require.NoError(t, err) + assert.Equal(t, EffectDeny, resultDeny.Effect, "Should deny when ALL roles are Prohibited") + }) +} diff --git a/weed/iam/policy/policy_engine.go b/weed/iam/policy/policy_engine.go index 086125948..7e5c494cf 100644 --- a/weed/iam/policy/policy_engine.go +++ b/weed/iam/policy/policy_engine.go @@ -23,10 +23,10 @@ const ( // Package-level regex cache for performance optimization var ( - regexCache = make(map[string]*regexp.Regexp) - regexCacheMu sync.RWMutex - policyVariablePattern = regexp.MustCompile(`\$\{([^}]+)\}`) - safePolicyVariables = map[string]bool{ + regexCache = make(map[string]*regexp.Regexp) + regexCacheMu sync.RWMutex + policyVariablePattern = regexp.MustCompile(`\$\{([^}]+)\}`) + safePolicyVariables = map[string]bool{ // AWS standard identity variables "aws:username": true, "aws:userid": true, @@ -675,60 +675,73 @@ func (e *PolicyEngine) matchesConditions(conditions map[string]map[string]interf // evaluateConditionBlock evaluates a single condition block func (e *PolicyEngine) evaluateConditionBlock(conditionType string, block map[string]interface{}, evalCtx *EvaluationContext) bool { + // Parse set operators (prefixes) + forAllValues := false + if strings.HasPrefix(conditionType, "ForAllValues:") { + forAllValues = true + conditionType = strings.TrimPrefix(conditionType, "ForAllValues:") + } else if strings.HasPrefix(conditionType, "ForAnyValue:") { + conditionType = strings.TrimPrefix(conditionType, "ForAnyValue:") + // ForAnyValue is the default behavior (Any context value matches Any condition value), + // so we just strip the prefix + } + switch conditionType { // IP Address conditions case "IpAddress": - return e.evaluateIPCondition(block, evalCtx, true) + return e.evaluateIPCondition(block, evalCtx, true, forAllValues) case "NotIpAddress": - return e.evaluateIPCondition(block, evalCtx, false) + return e.evaluateIPCondition(block, evalCtx, false, forAllValues) // String conditions case "StringEquals": - return e.EvaluateStringCondition(block, evalCtx, true, false) + return e.EvaluateStringCondition(block, evalCtx, true, false, forAllValues) case "StringNotEquals": - return e.EvaluateStringCondition(block, evalCtx, false, false) + return e.EvaluateStringCondition(block, evalCtx, false, false, forAllValues) case "StringLike": - return e.EvaluateStringCondition(block, evalCtx, true, true) + return e.EvaluateStringCondition(block, evalCtx, true, true, forAllValues) case "StringNotLike": - return e.EvaluateStringCondition(block, evalCtx, false, true) + return e.EvaluateStringCondition(block, evalCtx, false, true, forAllValues) case "StringEqualsIgnoreCase": - return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, false) + return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, false, forAllValues) case "StringNotEqualsIgnoreCase": - return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, false) + return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, false, forAllValues) case "StringNotLikeIgnoreCase": - return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, true) + return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, true, forAllValues) + case "StringLikeIgnoreCase": + return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, true, forAllValues) // Numeric conditions case "NumericEquals": - return e.evaluateNumericCondition(block, evalCtx, "==") + return e.evaluateNumericCondition(block, evalCtx, "==", forAllValues) case "NumericNotEquals": - return e.evaluateNumericCondition(block, evalCtx, "!=") + return e.evaluateNumericCondition(block, evalCtx, "!=", forAllValues) case "NumericLessThan": - return e.evaluateNumericCondition(block, evalCtx, "<") + return e.evaluateNumericCondition(block, evalCtx, "<", forAllValues) case "NumericLessThanEquals": - return e.evaluateNumericCondition(block, evalCtx, "<=") + return e.evaluateNumericCondition(block, evalCtx, "<=", forAllValues) case "NumericGreaterThan": - return e.evaluateNumericCondition(block, evalCtx, ">") + return e.evaluateNumericCondition(block, evalCtx, ">", forAllValues) case "NumericGreaterThanEquals": - return e.evaluateNumericCondition(block, evalCtx, ">=") + return e.evaluateNumericCondition(block, evalCtx, ">=", forAllValues) // Date conditions case "DateEquals": - return e.evaluateDateCondition(block, evalCtx, "==") + return e.evaluateDateCondition(block, evalCtx, "==", forAllValues) case "DateNotEquals": - return e.evaluateDateCondition(block, evalCtx, "!=") + return e.evaluateDateCondition(block, evalCtx, "!=", forAllValues) case "DateLessThan": - return e.evaluateDateCondition(block, evalCtx, "<") + return e.evaluateDateCondition(block, evalCtx, "<", forAllValues) case "DateLessThanEquals": - return e.evaluateDateCondition(block, evalCtx, "<=") + return e.evaluateDateCondition(block, evalCtx, "<=", forAllValues) case "DateGreaterThan": - return e.evaluateDateCondition(block, evalCtx, ">") + return e.evaluateDateCondition(block, evalCtx, ">", forAllValues) case "DateGreaterThanEquals": - return e.evaluateDateCondition(block, evalCtx, ">=") + return e.evaluateDateCondition(block, evalCtx, ">=", forAllValues) // Boolean conditions case "Bool": - return e.evaluateBoolCondition(block, evalCtx) + return e.evaluateBoolCondition(block, evalCtx, forAllValues) // Null conditions case "Null": @@ -741,54 +754,142 @@ func (e *PolicyEngine) evaluateConditionBlock(conditionType string, block map[st } // evaluateIPCondition evaluates IP address conditions -func (e *PolicyEngine) evaluateIPCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool) bool { - sourceIP, exists := evalCtx.RequestContext["aws:SourceIp"] - if !exists { - return !shouldMatch // If no IP in context, condition fails for positive match - } +func (e *PolicyEngine) evaluateIPCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, forAllValues bool) bool { + for conditionKey, conditionValue := range block { + contextValue, exists := evalCtx.RequestContext[conditionKey] + if !exists { + // If missing key: fails positive match, skips negative match + if shouldMatch { + return false + } + continue + } - sourceIPStr, ok := sourceIP.(string) - if !ok { - return !shouldMatch - } + // Normalize context values + var contextIPs []string + switch v := contextValue.(type) { + case string: + contextIPs = []string{v} + case []string: + contextIPs = v + case []interface{}: + for _, item := range v { + if s, ok := item.(string); ok { + contextIPs = append(contextIPs, s) + } + } + default: + contextIPs = []string{fmt.Sprintf("%v", contextValue)} + } - sourceIPAddr := net.ParseIP(sourceIPStr) - if sourceIPAddr == nil { - return !shouldMatch - } + // Normalize policy ranges + expectedRanges := normalizeRanges(conditionValue) - for key, value := range block { - if key == "aws:SourceIp" { - ranges, ok := value.([]string) - if !ok { - continue + if forAllValues { + // All context values must match at least one expected range + if len(contextIPs) == 0 { + continue // Vacuously true } - for _, ipRange := range ranges { - if strings.Contains(ipRange, "/") { - // CIDR range - _, cidr, err := net.ParseCIDR(ipRange) - if err != nil { - continue - } - if cidr.Contains(sourceIPAddr) { - return shouldMatch + for _, ctxIPStr := range contextIPs { + ctxIP := net.ParseIP(ctxIPStr) + if ctxIP == nil { + return false + } + + itemMatchedInRange := false + for _, ipRange := range expectedRanges { + if strings.Contains(ipRange, "/") { + _, cidr, err := net.ParseCIDR(ipRange) + if err == nil && cidr.Contains(ctxIP) { + itemMatchedInRange = true + break + } + } else if ctxIPStr == ipRange { + itemMatchedInRange = true + break } - } else { - // Single IP - if sourceIPStr == ipRange { - return shouldMatch + } + + // Apply operator (IPAddress vs NotIPAddress) + satisfied := itemMatchedInRange + if !shouldMatch { + satisfied = !itemMatchedInRange + } + + if !satisfied { + return false + } + } + } else { + // ForAnyValue or standard: Any context value matches any expected range + if len(contextIPs) == 0 { + return false // AWS behavior for ForAnyValue with empty sets + } + + anySatisfied := false + for _, ctxIPStr := range contextIPs { + ctxIP := net.ParseIP(ctxIPStr) + if ctxIP == nil { + continue + } + + itemMatchedInRange := false + for _, ipRange := range expectedRanges { + if strings.Contains(ipRange, "/") { + _, cidr, err := net.ParseCIDR(ipRange) + if err == nil && cidr.Contains(ctxIP) { + itemMatchedInRange = true + break + } + } else if ctxIPStr == ipRange { + itemMatchedInRange = true + break } } + + // Apply operator (IPAddress vs NotIPAddress) + satisfied := itemMatchedInRange + if !shouldMatch { + satisfied = !itemMatchedInRange + } + + if satisfied { + anySatisfied = true + break + } + } + + if !anySatisfied { + return false } } } + return true +} - return !shouldMatch +// normalizeRanges converts policy values into a []string +func normalizeRanges(value interface{}) []string { + switch v := value.(type) { + case string: + return []string{v} + case []string: + return v + case []interface{}: + var ranges []string + for _, item := range v { + if s, ok := item.(string); ok { + ranges = append(ranges, s) + } + } + return ranges + default: + return nil + } } // EvaluateStringCondition evaluates string-based conditions -func (e *PolicyEngine) EvaluateStringCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool) bool { +func (e *PolicyEngine) EvaluateStringCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool, forAllValues bool) bool { // Iterate through all condition keys in the block for conditionKey, conditionValue := range block { // Get the context values for this condition key @@ -839,37 +940,91 @@ func (e *PolicyEngine) EvaluateStringCondition(block map[string]interface{}, eva } // Evaluate the condition using AWS IAM-compliant matching - conditionMet := false - for _, expected := range expectedStrings { + if forAllValues { + // ForAllValues: Every value in the request context must match at least one value in the condition policy + // If context has no values, ForAllValues returns true (vacuously true) + if len(contextStrings) == 0 { + continue + } + + // Iterate over each context value - it MUST satisfy the operator + allSatisfied := true for _, contextValue := range contextStrings { - if useWildcard { - // Use AWS IAM-compliant wildcard matching for StringLike conditions - // This handles case-insensitivity and policy variables - if awsIAMMatch(expected, contextValue, evalCtx) { - conditionMet = true - break - } - } else { - // For StringEquals/StringNotEquals, also support policy variables but be case-sensitive + contextValueMatchedSet := false + for _, expected := range expectedStrings { expandedExpected := expandPolicyVariables(expected, evalCtx) - if expandedExpected == contextValue { - conditionMet = true - break + if useWildcard { + // Use filepath.Match for case-sensitive wildcard matching, as required by StringLike + if matched, _ := filepath.Match(expandedExpected, contextValue); matched { + contextValueMatchedSet = true + break + } + } else { + if expandedExpected == contextValue { + contextValueMatchedSet = true + break + } } } + + // Apply operator (equals vs not-equals) + satisfied := contextValueMatchedSet + if !shouldMatch { + satisfied = !contextValueMatchedSet + } + + if !satisfied { + allSatisfied = false + break + } } - if conditionMet { - break + + if !allSatisfied { + return false } - } - // For shouldMatch=true (StringEquals, StringLike): condition must be met - // For shouldMatch=false (StringNotEquals): condition must NOT be met - if shouldMatch && !conditionMet { - return false - } - if !shouldMatch && conditionMet { - return false + } else { + // ForAnyValue (default): At least one value in the request context must match at least one value in the condition policy + // AWS IAM treats empty request sets as "no match" for ForAnyValue + if len(contextStrings) == 0 { + return false + } + + anySatisfied := false + for _, contextValue := range contextStrings { + contextValueMatchedSet := false + for _, expected := range expectedStrings { + expandedExpected := expandPolicyVariables(expected, evalCtx) + if useWildcard { + // Use filepath.Match for case-sensitive wildcard matching, as required by StringLike + if matched, _ := filepath.Match(expandedExpected, contextValue); matched { + contextValueMatchedSet = true + break + } + } else { + // For StringEquals/StringNotEquals, also support policy variables but be case-sensitive + if expandedExpected == contextValue { + contextValueMatchedSet = true + break + } + } + } + + // Apply operator (equals vs not-equals) + satisfied := contextValueMatchedSet + if !shouldMatch { + satisfied = !contextValueMatchedSet + } + + if satisfied { + anySatisfied = true + break + } + } + + if !anySatisfied { + return false + } } } @@ -1100,7 +1255,7 @@ func matchAction(pattern, action string) bool { } // evaluateStringConditionIgnoreCase evaluates string conditions with case insensitivity -func (e *PolicyEngine) evaluateStringConditionIgnoreCase(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool) bool { +func (e *PolicyEngine) evaluateStringConditionIgnoreCase(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool, forAllValues bool) bool { for key, expectedValues := range block { contextValue, exists := evalCtx.RequestContext[key] if !exists { @@ -1110,190 +1265,575 @@ func (e *PolicyEngine) evaluateStringConditionIgnoreCase(block map[string]interf return false } - contextStr, ok := contextValue.(string) - if !ok { - return false + // Convert context value to string slice + var contextStrings []string + switch v := contextValue.(type) { + case string: + contextStrings = []string{v} + case []string: + contextStrings = v + case []interface{}: + for _, item := range v { + if str, ok := item.(string); ok { + contextStrings = append(contextStrings, str) + } + } + default: + // Fallback for non-string types + contextStrings = []string{fmt.Sprintf("%v", contextValue)} } - contextStr = strings.ToLower(contextStr) - matched := false + if forAllValues { + // ForAllValues: Every value in context must match at least one expected value + if len(contextStrings) == 0 { + continue + } - // Handle different value types - switch v := expectedValues.(type) { - case string: - expectedStr := strings.ToLower(v) - if useWildcard { - matched, _ = filepath.Match(expectedStr, contextStr) - } else { - matched = expectedStr == contextStr + allSatisfied := true + for _, ctxStr := range contextStrings { + itemMatchedSet := false + + // Check against all expected values + switch v := expectedValues.(type) { + case string: + expandedPattern := expandPolicyVariables(v, evalCtx) + if useWildcard { + if AwsWildcardMatch(expandedPattern, ctxStr) { + itemMatchedSet = true + } + } else { + if strings.EqualFold(expandedPattern, ctxStr) { + itemMatchedSet = true + } + } + case []interface{}, []string: + var slice []string + if s, ok := v.([]string); ok { + slice = s + } else { + for _, item := range v.([]interface{}) { + if str, ok := item.(string); ok { + slice = append(slice, str) + } + } + } + for _, valStr := range slice { + expandedPattern := expandPolicyVariables(valStr, evalCtx) + if useWildcard { + if AwsWildcardMatch(expandedPattern, ctxStr) { + itemMatchedSet = true + break + } + } else { + if strings.EqualFold(expandedPattern, ctxStr) { + itemMatchedSet = true + break + } + } + } + } + + // Apply operator (equals vs not-equals) + satisfied := itemMatchedSet + if !shouldMatch { + satisfied = !itemMatchedSet + } + + if !satisfied { + allSatisfied = false + break + } } - case []interface{}: - for _, val := range v { - if valStr, ok := val.(string); ok { - expectedStr := strings.ToLower(valStr) + + if !allSatisfied { + return false + } + + } else { + // ForAnyValue (default): Any value in context must match any expected value + anySatisfied := false + for _, ctxStr := range contextStrings { + itemMatchedSet := false + + // Handle different value types + switch v := expectedValues.(type) { + case string: + expandedPattern := expandPolicyVariables(v, evalCtx) if useWildcard { - if m, _ := filepath.Match(expectedStr, contextStr); m { - matched = true - break + if AwsWildcardMatch(expandedPattern, ctxStr) { + itemMatchedSet = true } } else { - if expectedStr == contextStr { - matched = true - break + if strings.EqualFold(expandedPattern, ctxStr) { + itemMatchedSet = true + } + } + case []interface{}, []string: + var slice []string + if s, ok := v.([]string); ok { + slice = s + } else { + for _, item := range v.([]interface{}) { + if str, ok := item.(string); ok { + slice = append(slice, str) + } + } + } + for _, valStr := range slice { + expandedPattern := expandPolicyVariables(valStr, evalCtx) + if useWildcard { + if AwsWildcardMatch(expandedPattern, ctxStr) { + itemMatchedSet = true + break + } + } else { + if strings.EqualFold(expandedPattern, ctxStr) { + itemMatchedSet = true + break + } } } } + + // Apply operator (equals vs not-equals) + satisfied := itemMatchedSet + if !shouldMatch { + satisfied = !itemMatchedSet + } + + if satisfied { + anySatisfied = true + break + } } - } - if shouldMatch && !matched { - return false - } - if !shouldMatch && matched { - return false + if !anySatisfied { + return false + } } } return true } // evaluateNumericCondition evaluates numeric conditions -func (e *PolicyEngine) evaluateNumericCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string) bool { - +func (e *PolicyEngine) evaluateNumericCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string, forAllValues bool) bool { for key, expectedValues := range block { contextValue, exists := evalCtx.RequestContext[key] if !exists { - return false } - contextNum, err := parseNumeric(contextValue) - if err != nil { + // Parse context values (handle single or list) + var contextNums []float64 + switch v := contextValue.(type) { + case []interface{}: + for _, item := range v { + if num, err := parseNumeric(item); err == nil { + contextNums = append(contextNums, num) + } + } + case []string: + for _, item := range v { + if num, err := parseNumeric(item); err == nil { + contextNums = append(contextNums, num) + } + } + default: + if num, err := parseNumeric(v); err == nil { + contextNums = append(contextNums, num) + } + } + if len(contextNums) == 0 { + if forAllValues { + continue + } return false } - matched := false + if forAllValues { + // ForAllValues: All context nums must match at least one expected value + allMatch := true + for _, contextNum := range contextNums { + itemMatched := false + switch v := expectedValues.(type) { + case string: + if expectedNum, err := parseNumeric(v); err == nil { + itemMatched = compareNumbers(contextNum, expectedNum, operator) + } + case float64: + itemMatched = compareNumbers(contextNum, v, operator) + case int: + itemMatched = compareNumbers(contextNum, float64(v), operator) + case int64: + itemMatched = compareNumbers(contextNum, float64(v), operator) + case []interface{}, []string: + // Convert to unified slice of interface{} if it's []string + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } - // Handle different value types - switch v := expectedValues.(type) { - case string: - expectedNum, err := parseNumeric(v) - if err != nil { + if operator == "!=" { + // For NotEquals, itemMatched means it matches NONE of the expected values + anyMatch := false + for _, val := range slice { + if expectedNum, err := parseNumeric(val); err == nil { + if compareNumbers(contextNum, expectedNum, "==") { + anyMatch = true + break + } + } + } + itemMatched = !anyMatch + } else { + for _, val := range slice { + if expectedNum, err := parseNumeric(val); err == nil { + if compareNumbers(contextNum, expectedNum, operator) { + itemMatched = true + break + } + } + } + } + } + if !itemMatched { + allMatch = false + break + } + } + if !allMatch { return false } - matched = compareNumbers(contextNum, expectedNum, operator) - case float64: - matched = compareNumbers(contextNum, v, operator) - case int: - matched = compareNumbers(contextNum, float64(v), operator) - case int64: - matched = compareNumbers(contextNum, float64(v), operator) - case []interface{}: - for _, val := range v { - expectedNum, err := parseNumeric(val) - if err != nil { - continue + } else { + // ForAnyValue: Any context num must match any expected value + matched := false + for _, contextNum := range contextNums { + itemMatched := false + switch v := expectedValues.(type) { + case string: + if expectedNum, err := parseNumeric(v); err == nil { + itemMatched = compareNumbers(contextNum, expectedNum, operator) + } + case float64: + itemMatched = compareNumbers(contextNum, v, operator) + case int: + itemMatched = compareNumbers(contextNum, float64(v), operator) + case int64: + itemMatched = compareNumbers(contextNum, float64(v), operator) + case []interface{}, []string: + // Convert to unified slice of interface{} if it's []string + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } + + if operator == "!=" { + // For NotEquals, itemMatched means it matches NONE of the expected values + anyMatch := false + for _, val := range slice { + if expectedNum, err := parseNumeric(val); err == nil { + if compareNumbers(contextNum, expectedNum, "==") { + anyMatch = true + break + } + } + } + itemMatched = !anyMatch + } else { + for _, val := range slice { + if expectedNum, err := parseNumeric(val); err == nil { + if compareNumbers(contextNum, expectedNum, operator) { + itemMatched = true + break + } + } + } + } } - if compareNumbers(contextNum, expectedNum, operator) { + if itemMatched { matched = true break } } - default: - - } - - if !matched { - return false + if !matched { + return false + } } } return true } // evaluateDateCondition evaluates date conditions -func (e *PolicyEngine) evaluateDateCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string) bool { +func (e *PolicyEngine) evaluateDateCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string, forAllValues bool) bool { for key, expectedValues := range block { contextValue, exists := evalCtx.RequestContext[key] if !exists { return false } - contextTime, err := parseDateTime(contextValue) - if err != nil { + // Parse context values (handle single or list) + var contextTimes []time.Time + switch v := contextValue.(type) { + case []interface{}: + for _, item := range v { + if t, err := parseDateTime(item); err == nil { + contextTimes = append(contextTimes, t) + } + } + case []string: + for _, item := range v { + if t, err := parseDateTime(item); err == nil { + contextTimes = append(contextTimes, t) + } + } + default: + if t, err := parseDateTime(v); err == nil { + contextTimes = append(contextTimes, t) + } + } + + if len(contextTimes) == 0 { + if forAllValues { + continue + } return false } - matched := false + if forAllValues { + allMatch := true + for _, contextTime := range contextTimes { + itemMatched := false + switch v := expectedValues.(type) { + case string: + if expectedTime, err := parseDateTime(v); err == nil { + itemMatched = compareDates(contextTime, expectedTime, operator) + } + case []interface{}, []string: + // Convert to unified slice of interface{} if it's []string + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } - // Handle different value types - switch v := expectedValues.(type) { - case string: - expectedTime, err := parseDateTime(v) - if err != nil { + if operator == "!=" { + // For NotEquals, itemMatched means it matches NONE of the expected values + anyMatch := false + for _, val := range slice { + if expectedTime, err := parseDateTime(val); err == nil { + if compareDates(contextTime, expectedTime, "==") { + anyMatch = true + break + } + } + } + itemMatched = !anyMatch + } else { + for _, val := range slice { + if expectedTime, err := parseDateTime(val); err == nil { + if compareDates(contextTime, expectedTime, operator) { + itemMatched = true + break + } + } + } + } + } + if !itemMatched { + allMatch = false + break + } + } + if !allMatch { return false } - matched = compareDates(contextTime, expectedTime, operator) - case []interface{}: - for _, val := range v { - expectedTime, err := parseDateTime(val) - if err != nil { - continue + } else { + matched := false + for _, contextTime := range contextTimes { + itemMatched := false + switch v := expectedValues.(type) { + case string: + if expectedTime, err := parseDateTime(v); err == nil { + itemMatched = compareDates(contextTime, expectedTime, operator) + } + case []interface{}, []string: + // Convert to unified slice of interface{} if it's []string + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } + + if operator == "!=" { + // For NotEquals, itemMatched means it matches NONE of the expected values + anyMatch := false + for _, val := range slice { + if expectedTime, err := parseDateTime(val); err == nil { + if compareDates(contextTime, expectedTime, "==") { + anyMatch = true + break + } + } + } + itemMatched = !anyMatch + } else { + for _, val := range slice { + if expectedTime, err := parseDateTime(val); err == nil { + if compareDates(contextTime, expectedTime, operator) { + itemMatched = true + break + } + } + } + } } - if compareDates(contextTime, expectedTime, operator) { + if itemMatched { matched = true break } } - } - - if !matched { - return false + if !matched { + return false + } } } return true } // evaluateBoolCondition evaluates boolean conditions -func (e *PolicyEngine) evaluateBoolCondition(block map[string]interface{}, evalCtx *EvaluationContext) bool { +func (e *PolicyEngine) evaluateBoolCondition(block map[string]interface{}, evalCtx *EvaluationContext, forAllValues bool) bool { for key, expectedValues := range block { contextValue, exists := evalCtx.RequestContext[key] if !exists { return false } - contextBool, err := parseBool(contextValue) - if err != nil { - return false + // Parse context values (handle single or list) + var contextBools []bool + switch v := contextValue.(type) { + case []interface{}: + for _, item := range v { + if b, err := parseBool(item); err == nil { + contextBools = append(contextBools, b) + } + } + case []string: + for _, item := range v { + if b, err := parseBool(item); err == nil { + contextBools = append(contextBools, b) + } + } + default: + if b, err := parseBool(v); err == nil { + contextBools = append(contextBools, b) + } } - matched := false + if len(contextBools) == 0 { + if forAllValues { + continue + } + return false + } - // Handle different value types - switch v := expectedValues.(type) { - case string: - expectedBool, err := parseBool(v) - if err != nil { + if forAllValues { + allMatch := true + for _, contextBool := range contextBools { + itemMatched := false + switch v := expectedValues.(type) { + case string: + if expectedBool, err := parseBool(v); err == nil { + itemMatched = contextBool == expectedBool + } + case bool: + itemMatched = contextBool == v + case []interface{}, []string: + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } + for _, val := range slice { + expectedBool, err := parseBool(val) + if err != nil { + continue + } + if contextBool == expectedBool { + itemMatched = true + break + } + } + } + if !itemMatched { + allMatch = false + break + } + } + if !allMatch { return false } - matched = contextBool == expectedBool - case bool: - matched = contextBool == v - case []interface{}: - for _, val := range v { - expectedBool, err := parseBool(val) - if err != nil { - continue + } else { + matched := false + for _, contextBool := range contextBools { + switch v := expectedValues.(type) { + case string: + if expectedBool, err := parseBool(v); err == nil { + matched = contextBool == expectedBool + } + case bool: + matched = contextBool == v + case []interface{}, []string: + var slice []interface{} + if s, ok := v.([]string); ok { + slice = make([]interface{}, len(s)) + for i, item := range s { + slice[i] = item + } + } else { + slice = v.([]interface{}) + } + for _, val := range slice { + expectedBool, err := parseBool(val) + if err != nil { + continue + } + if contextBool == expectedBool { + matched = true + break + } + } } - if contextBool == expectedBool { - matched = true + if matched { break } } - } - - if !matched { - return false + if !matched { + return false + } } } return true diff --git a/weed/iam/policy/policy_variable_matching_test.go b/weed/iam/policy/policy_variable_matching_test.go index 6b9827dff..ad0e0f3a4 100644 --- a/weed/iam/policy/policy_variable_matching_test.go +++ b/weed/iam/policy/policy_variable_matching_test.go @@ -156,7 +156,7 @@ func TestActionResourceConsistencyWithStringConditions(t *testing.T) { Action: []string{"S3:GET*"}, // Uppercase action pattern Resource: []string{"arn:aws:s3:::TEST-BUCKET/*"}, // Uppercase resource pattern Condition: map[string]map[string]interface{}{ - "StringLike": { + "StringLikeIgnoreCase": { "s3:RequestedRegion": "US-*", // Uppercase condition pattern }, }, @@ -184,7 +184,7 @@ func TestActionResourceConsistencyWithStringConditions(t *testing.T) { "Actions, Resources, and Conditions should all use case-insensitive AWS IAM matching") // Verify that matching statements were found - assert.Len(t, result.MatchingStatements, 1, + require.Len(t, result.MatchingStatements, 1, "Should have exactly one matching statement") assert.Equal(t, "Allow", string(result.MatchingStatements[0].Effect), "Matching statement should have Allow effect")