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")