From 4eb45ecc5ee9ecbfcb2740f7f9bc60729a83dbf8 Mon Sep 17 00:00:00 2001 From: SrikanthBhandary Date: Thu, 5 Mar 2026 21:13:18 +0100 Subject: [PATCH] s3api: add IAM policy fallback authorization tests (#8518) * s3api: add IAM policy fallback auth with tests * s3api: use policy engine for IAM fallback evaluation --- weed/s3api/auth_credentials.go | 56 ++++++++++++ weed/s3api/auth_credentials_test.go | 127 ++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index cadc48b66..9e0a0493e 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -21,6 +21,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/util/wildcard" @@ -1658,6 +1659,52 @@ func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthP return iamAuthPathNone } +// evaluateIAMPolicies evaluates attached IAM policies for a user identity. +// Returns true if any matching statement explicitly allows the action. +func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identity *Identity, action Action, bucket, object string) bool { + if identity == nil || len(identity.PolicyNames) == 0 { + return false + } + + resource := buildResourceARN(bucket, object) + principal := buildPrincipalARN(identity, r) + s3Action := ResolveS3Action(r, string(action), bucket, object) + explicitAllow := false + conditions := policy_engine.ExtractConditionValuesFromRequest(r) + for k, v := range policy_engine.ExtractPrincipalVariables(principal) { + conditions[k] = v + } + + for _, policyName := range identity.PolicyNames { + policy, err := iam.GetPolicy(policyName) + if err != nil { + continue + } + + engine := policy_engine.NewPolicyEngine() + if err := engine.SetBucketPolicy(policyName, policy.Content); err != nil { + continue + } + + result := engine.EvaluatePolicy(policyName, &policy_engine.PolicyEvaluationArgs{ + Action: s3Action, + Resource: resource, + Principal: principal, + Conditions: conditions, + Claims: identity.Claims, + }) + + if result == policy_engine.PolicyResultDeny { + return false + } + if result == policy_engine.PolicyResultAllow { + explicitAllow = true + } + } + + return explicitAllow +} + // VerifyActionPermission checks if the identity is allowed to perform the action on the resource. // It handles both traditional identities (via Actions) and IAM/STS identities (via Policy). func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, identity *Identity, action Action, bucket, object string) s3err.ErrorCode { @@ -1679,6 +1726,7 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide return iam.authorizeWithIAM(r, identity, action, bucket, object) } + // Traditional actions-based authorization from static S3 config. if len(identity.Actions) > 0 { if !identity.CanDo(action, bucket, object) { return s3err.ErrAccessDenied @@ -1686,6 +1734,14 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide return s3err.ErrNone } + // IAM policy fallback for identities with attached policies but without IAM integration. + if len(identity.PolicyNames) > 0 { + if iam.evaluateIAMPolicies(r, identity, action, bucket, object) { + return s3err.ErrNone + } + return s3err.ErrAccessDenied + } + return s3err.ErrAccessDenied } diff --git a/weed/s3api/auth_credentials_test.go b/weed/s3api/auth_credentials_test.go index 5e4f80ee6..1a4f5ac41 100644 --- a/weed/s3api/auth_credentials_test.go +++ b/weed/s3api/auth_credentials_test.go @@ -1,7 +1,9 @@ package s3api import ( + "crypto/tls" "fmt" + "net/http" "os" "reflect" "sync" @@ -9,6 +11,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/credential" . "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/util/wildcard" "github.com/stretchr/testify/assert" @@ -260,6 +263,130 @@ func TestMatchWildcardPattern(t *testing.T) { } } +func TestVerifyActionPermissionPolicyFallback(t *testing.T) { + buildRequest := func(t *testing.T, method string) *http.Request { + t.Helper() + req, err := http.NewRequest(method, "http://s3.amazonaws.com/test-bucket/test-object", nil) + assert.NoError(t, err) + return req + } + + t.Run("policy allow grants access", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("allowGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`) + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"allowGet"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object") + assert.Equal(t, s3err.ErrNone, errCode) + }) + + t.Run("explicit deny overrides allow", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("allowAllGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`) + assert.NoError(t, err) + err = iam.PutPolicy("denySecret", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/secret.txt"}]}`) + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"allowAllGet", "denySecret"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "secret.txt") + assert.Equal(t, s3err.ErrAccessDenied, errCode) + }) + + t.Run("implicit deny when no statement matches", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("allowOtherBucket", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::other-bucket/*"}]}`) + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"allowOtherBucket"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object") + assert.Equal(t, s3err.ErrAccessDenied, errCode) + }) + + t.Run("invalid policy document does not allow", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("invalidPolicy", "{not-json") + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"invalidPolicy"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object") + assert.Equal(t, s3err.ErrAccessDenied, errCode) + }) + + t.Run("notresource excludes denied object", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("denyNotResource", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","NotResource":"arn:aws:s3:::test-bucket/public/*"}]}`) + assert.NoError(t, err) + err = iam.PutPolicy("allowAllGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`) + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"allowAllGet", "denyNotResource"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "private/secret.txt") + assert.Equal(t, s3err.ErrAccessDenied, errCode) + + errCode = iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "public/readme.txt") + assert.Equal(t, s3err.ErrNone, errCode) + }) + + t.Run("condition securetransport enforced", func(t *testing.T) { + iam := &IdentityAccessManagement{} + err := iam.PutPolicy("allowTLSOnly", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}`) + assert.NoError(t, err) + + identity := &Identity{ + Name: "policy-user", + Account: &AccountAdmin, + PolicyNames: []string{"allowTLSOnly"}, + } + + httpReq := buildRequest(t, http.MethodGet) + errCode := iam.VerifyActionPermission(httpReq, identity, Action(ACTION_READ), "test-bucket", "test-object") + assert.Equal(t, s3err.ErrAccessDenied, errCode) + + httpsReq := buildRequest(t, http.MethodGet) + httpsReq.TLS = &tls.ConnectionState{} + errCode = iam.VerifyActionPermission(httpsReq, identity, Action(ACTION_READ), "test-bucket", "test-object") + assert.Equal(t, s3err.ErrNone, errCode) + }) + + t.Run("actions based path still works", func(t *testing.T) { + iam := &IdentityAccessManagement{} + identity := &Identity{ + Name: "legacy-user", + Account: &AccountAdmin, + Actions: []Action{"Read:test-bucket"}, + } + + errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "any-object") + assert.Equal(t, s3err.ErrNone, errCode) + }) +} + type LoadS3ApiConfigurationTestCase struct { pbAccount *iam_pb.Account pbIdent *iam_pb.Identity