Browse Source

Add AWS IAM integration tests and refactor admin authorization (#8098)

* Add AWS IAM integration tests and refactor admin authorization
- Added AWS IAM management integration tests (User, AccessKey, Policy)
- Updated test framework to support IAM client creation with JWT/OIDC
- Refactored s3api authorization to be policy-driven for IAM actions
- Removed hardcoded role name checks for admin privileges
- Added new tests to GitHub Actions basic test matrix

* test(s3/iam): add UpdateUser and UpdateAccessKey tests and fix nil pointer dereference

* feat(s3api): add DeletePolicy and update tests with cleanup logic

* test(s3/iam): use t.Cleanup for managed policy deletion in CreatePolicy test
pull/8095/merge
Chris Lu 1 day ago
committed by GitHub
parent
commit
535be3096b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/s3-iam-tests.yml
  2. 8
      test/s3/iam/iam_config.json
  3. 8
      test/s3/iam/iam_config.local.json
  4. 241
      test/s3/iam/s3_iam_admin_test.go
  5. 51
      test/s3/iam/s3_iam_framework.go
  6. 107
      test/s3/iam/test_config.json
  7. 4
      weed/s3api/s3_action_resolver.go
  8. 5
      weed/s3api/s3_iam_middleware.go
  9. 22
      weed/s3api/s3api_embedded_iam.go

2
.github/workflows/s3-iam-tests.yml

@ -117,7 +117,7 @@ jobs:
"basic")
echo "Running basic IAM functionality tests..."
make clean setup start-services wait-for-services
go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation" ./...
go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAM" ./...
;;
"advanced")
echo "Running advanced IAM feature tests..."

8
test/s3/iam/iam_config.json

@ -230,10 +230,12 @@
{
"Effect": "Allow",
"Action": [
"s3:*"
"s3:*",
"iam:*"
],
"Resource": [
"*"
"*",
"arn:aws:iam:::*"
]
},
{
@ -342,4 +344,4 @@
}
}
]
}
}

8
test/s3/iam/iam_config.local.json

@ -230,10 +230,12 @@
{
"Effect": "Allow",
"Action": [
"s3:*"
"s3:*",
"iam:*"
],
"Resource": [
"*"
"*",
"arn:aws:iam:::*"
]
},
{
@ -342,4 +344,4 @@
}
}
]
}
}

241
test/s3/iam/s3_iam_admin_test.go

@ -0,0 +1,241 @@
package iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIAMUserManagement tests user management operations
func TestIAMUserManagement(t *testing.T) {
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
// Create IAM client with admin privileges
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
t.Run("create_and_get_user", func(t *testing.T) {
userName := "test-user-mgm"
// Create user
createResp, err := iamClient.CreateUser(&iam.CreateUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
assert.Equal(t, userName, *createResp.User.UserName)
// Get user
getResp, err := iamClient.GetUser(&iam.GetUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
assert.Equal(t, userName, *getResp.User.UserName)
// List users to verify existence
listResp, err := iamClient.ListUsers(&iam.ListUsersInput{})
require.NoError(t, err)
found := false
for _, user := range listResp.Users {
if *user.UserName == userName {
found = true
break
}
}
assert.True(t, found, "Created user should be listed")
// Clean up
_, err = iamClient.DeleteUser(&iam.DeleteUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
})
t.Run("update_user", func(t *testing.T) {
userName := "user-to-update"
newUserName := "user-updated"
// Create user
_, err := iamClient.CreateUser(&iam.CreateUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
defer func() {
// Try to delete both just in case
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(newUserName)})
}()
// Update user name
_, err = iamClient.UpdateUser(&iam.UpdateUserInput{
UserName: aws.String(userName),
NewUserName: aws.String(newUserName),
})
require.NoError(t, err)
// Verify update (GetUser with NEW name should work)
getResp, err := iamClient.GetUser(&iam.GetUserInput{
UserName: aws.String(newUserName),
})
require.NoError(t, err)
assert.Equal(t, newUserName, *getResp.User.UserName)
// GetUser with OLD name should fail
_, err = iamClient.GetUser(&iam.GetUserInput{
UserName: aws.String(userName),
})
require.Error(t, err)
})
}
// TestIAMAccessKeyManagement tests access key operations
func TestIAMAccessKeyManagement(t *testing.T) {
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
userName := "test-user-keys"
_, err = iamClient.CreateUser(&iam.CreateUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
t.Run("create_list_delete_access_key", func(t *testing.T) {
// Create access key
createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
assert.NotEmpty(t, *createResp.AccessKey.AccessKeyId)
assert.NotEmpty(t, *createResp.AccessKey.SecretAccessKey)
assert.Equal(t, "Active", *createResp.AccessKey.Status)
// List access keys
listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
assert.Equal(t, 1, len(listResp.AccessKeyMetadata))
assert.Equal(t, *createResp.AccessKey.AccessKeyId, *listResp.AccessKeyMetadata[0].AccessKeyId)
// Delete access key
_, err = iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
UserName: aws.String(userName),
AccessKeyId: createResp.AccessKey.AccessKeyId,
})
require.NoError(t, err)
// Verify deletion
listResp, err = iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
assert.Equal(t, 0, len(listResp.AccessKeyMetadata))
})
t.Run("update_access_key_status", func(t *testing.T) {
// Create access key
createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
UserName: aws.String(userName),
AccessKeyId: createResp.AccessKey.AccessKeyId,
})
// Update to Inactive
_, err = iamClient.UpdateAccessKey(&iam.UpdateAccessKeyInput{
UserName: aws.String(userName),
AccessKeyId: createResp.AccessKey.AccessKeyId,
Status: aws.String("Inactive"),
})
require.NoError(t, err)
// Verify update in ListAccessKeys
listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
found := false
for _, key := range listResp.AccessKeyMetadata {
if *key.AccessKeyId == *createResp.AccessKey.AccessKeyId {
assert.Equal(t, "Inactive", *key.Status)
found = true
break
}
}
assert.True(t, found)
})
}
// TestIAMPolicyManagement tests policy operations
func TestIAMPolicyManagement(t *testing.T) {
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
t.Run("create_managed_policy", func(t *testing.T) {
policyName := "test-managed-policy"
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(policyDoc),
})
require.NoError(t, err)
assert.Equal(t, policyName, *createResp.Policy.PolicyName)
assert.NotEmpty(t, *createResp.Policy.Arn)
t.Cleanup(func() {
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{
PolicyArn: createResp.Policy.Arn,
})
})
})
t.Run("user_inline_policy", func(t *testing.T) {
userName := "test-user-policy"
_, err := iamClient.CreateUser(&iam.CreateUserInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
policyName := "test-inline-policy"
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}`
// Put user policy
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(policyDoc),
})
require.NoError(t, err)
// Get user policy
getResp, err := iamClient.GetUserPolicy(&iam.GetUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
})
require.NoError(t, err)
assert.Equal(t, userName, *getResp.UserName)
assert.Equal(t, policyName, *getResp.PolicyName)
assert.Contains(t, *getResp.PolicyDocument, "s3:Get")
// Delete user policy
_, err = iamClient.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
})
require.NoError(t, err)
})
}

51
test/s3/iam/s3_iam_framework.go

@ -21,6 +21,7 @@ import (
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
@ -683,13 +684,13 @@ func (f *S3IAMTestFramework) GenerateUniqueBucketName(prefix string) string {
randomSuffix := mathrand.Intn(10000)
bucketName := fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix)
// Ensure final name is valid
if len(bucketName) > 63 {
// Truncate further if necessary
bucketName = bucketName[:63]
}
return bucketName
}
@ -904,3 +905,49 @@ func (f *S3IAMTestFramework) WaitForS3ServiceSimple() error {
// The full implementation would be in the Makefile's wait-for-services target
return nil
}
// CreateIAMClientWithJWT creates an IAM client authenticated with a JWT token for the specified role
func (f *S3IAMTestFramework) CreateIAMClientWithJWT(username, roleName string) (*iam.IAM, error) {
return f.CreateIAMClientWithCustomClaims(username, roleName, "", nil)
}
// CreateIAMClientWithCustomClaims creates an IAM client with specific account ID and custom claims
func (f *S3IAMTestFramework) CreateIAMClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*iam.IAM, error) {
var token string
var err error
if f.useKeycloak && claims == nil && account == "" {
// Use real Keycloak authentication if no custom requirements
token, err = f.getKeycloakToken(username)
if err != nil {
return nil, fmt.Errorf("failed to get Keycloak token: %v", err)
}
} else {
// Generate STS session token (mock mode or custom requirements)
token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims)
if err != nil {
return nil, fmt.Errorf("failed to generate STS session token: %v", err)
}
}
// Create custom HTTP client with Bearer token transport
httpClient := &http.Client{
Transport: &BearerTokenTransport{
Token: token,
},
}
sess, err := session.NewSession(&aws.Config{
Region: aws.String(TestRegion),
Endpoint: aws.String(TestS3Endpoint),
HTTPClient: httpClient,
// Use anonymous credentials to avoid AWS signature generation
Credentials: credentials.AnonymousCredentials,
DisableSSL: aws.Bool(true),
})
if err != nil {
return nil, fmt.Errorf("failed to create AWS session: %v", err)
}
return iam.New(sess), nil
}

107
test/s3/iam/test_config.json

@ -8,27 +8,33 @@
"secretKey": "test-secret-key"
}
],
"actions": ["Admin"]
"actions": [
"Admin"
]
},
{
"name": "readonlyuser",
"name": "readonlyuser",
"credentials": [
{
"accessKey": "readonly-access-key",
"secretKey": "readonly-secret-key"
}
],
"actions": ["Read"]
"actions": [
"Read"
]
},
{
"name": "writeonlyuser",
"credentials": [
{
"accessKey": "writeonly-access-key",
"accessKey": "writeonly-access-key",
"secretKey": "writeonly-secret-key"
}
],
"actions": ["Write"]
"actions": [
"Write"
]
}
],
"iam": {
@ -52,7 +58,7 @@
"rules": [
{
"claim": "groups",
"claimValue": "admins",
"claimValue": "admins",
"roleName": "S3AdminRole"
},
{
@ -78,13 +84,13 @@
"test-ldap": {
"server": "ldap://localhost:389",
"baseDN": "dc=example,dc=com",
"bindDN": "cn=admin,dc=example,dc=com",
"bindDN": "cn=admin,dc=example,dc=com",
"bindPassword": "admin-password",
"userFilter": "(uid=%s)",
"groupFilter": "(memberUid=%s)",
"attributes": {
"email": "mail",
"displayName": "cn",
"displayName": "cn",
"groups": "memberOf"
},
"roleMapping": {
@ -95,7 +101,7 @@
"roleName": "S3AdminRole"
},
{
"claim": "groups",
"claim": "groups",
"claimValue": "cn=users,ou=groups,dc=example,dc=com",
"roleName": "S3ReadOnlyRole"
}
@ -114,13 +120,18 @@
{
"Effect": "Allow",
"Principal": {
"Federated": ["test-oidc", "test-ldap"]
"Federated": [
"test-oidc",
"test-ldap"
]
},
"Action": "sts:AssumeRoleWithWebIdentity"
}
]
},
"attachedPolicies": ["S3AdminPolicy"],
"attachedPolicies": [
"S3AdminPolicy"
],
"description": "Full administrative access to S3 resources"
},
"S3ReadOnlyRole": {
@ -128,15 +139,20 @@
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Effect": "Allow",
"Principal": {
"Federated": ["test-oidc", "test-ldap"]
"Federated": [
"test-oidc",
"test-ldap"
]
},
"Action": "sts:AssumeRoleWithWebIdentity"
}
]
},
"attachedPolicies": ["S3ReadOnlyPolicy"],
"attachedPolicies": [
"S3ReadOnlyPolicy"
],
"description": "Read-only access to S3 resources"
},
"S3WriteOnlyRole": {
@ -146,13 +162,18 @@
{
"Effect": "Allow",
"Principal": {
"Federated": ["test-oidc", "test-ldap"]
"Federated": [
"test-oidc",
"test-ldap"
]
},
"Action": "sts:AssumeRoleWithWebIdentity"
}
]
},
"attachedPolicies": ["S3WriteOnlyPolicy"],
"attachedPolicies": [
"S3WriteOnlyPolicy"
],
"description": "Write-only access to S3 resources"
}
},
@ -162,22 +183,26 @@
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Action": [
"s3:*",
"iam:*"
],
"Resource": [
"arn:aws:s3:::*",
"arn:aws:s3:::*/*"
"arn:aws:s3:::*/*",
"arn:aws:iam:::*"
]
}
]
},
"S3ReadOnlyPolicy": {
"Version": "2012-10-17",
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetObjectVersion",
"s3:ListBucket",
"s3:ListBucketVersions",
"s3:GetBucketLocation",
@ -201,7 +226,7 @@
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:InitiateMultipartUpload",
"s3:UploadPart",
"s3:UploadPart",
"s3:CompleteMultipartUpload",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
@ -219,7 +244,7 @@
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:DeleteBucket",
"s3:DeleteBucket",
"s3:GetBucketPolicy",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
@ -237,14 +262,19 @@
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Action": [
"s3:*"
],
"Resource": [
"arn:aws:s3:::*",
"arn:aws:s3:::*/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": ["192.168.1.0/24", "10.0.0.0/8"]
"aws:SourceIp": [
"192.168.1.0/24",
"10.0.0.0/8"
]
}
}
}
@ -254,8 +284,11 @@
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::*",
"arn:aws:s3:::*/*"
@ -265,7 +298,7 @@
"aws:CurrentTime": "2023-01-01T00:00:00Z"
},
"DateLessThan": {
"aws:CurrentTime": "2025-12-31T23:59:59Z"
"aws:CurrentTime": "2025-12-31T23:59:59Z"
}
}
}
@ -280,7 +313,7 @@
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-bucket/*"
}
]
@ -292,7 +325,10 @@
"Sid": "DenyDeleteOperations",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:DeleteObject", "s3:DeleteBucket"],
"Action": [
"s3:DeleteObject",
"s3:DeleteBucket"
],
"Resource": [
"arn:aws:s3:::example-bucket",
"arn:aws:s3:::example-bucket/*"
@ -305,17 +341,22 @@
"Statement": [
{
"Sid": "IPRestrictedAccess",
"Effect": "Allow",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::example-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": ["203.0.113.0/24"]
"aws:SourceIp": [
"203.0.113.0/24"
]
}
}
}
]
}
}
}
}

4
weed/s3api/s3_action_resolver.go

@ -289,8 +289,8 @@ func resolveBucketLevelAction(method string, baseAction string) string {
// mapBaseActionToS3Format converts coarse-grained base actions to S3 format
// This is the fallback when no specific resolution is found
func mapBaseActionToS3Format(baseAction string) string {
// Handle actions that already have s3: prefix
if strings.HasPrefix(baseAction, "s3:") {
// Handle actions that already have s3: or iam: prefix
if strings.HasPrefix(baseAction, "s3:") || strings.HasPrefix(baseAction, "iam:") {
return baseAction
}

5
weed/s3api/s3_iam_middleware.go

@ -341,6 +341,11 @@ type MockAssumedRoleUser struct {
// buildS3ResourceArn builds an S3 resource ARN from bucket and object
func buildS3ResourceArn(bucket string, objectKey string) string {
// If bucket is already an ARN, return it as-is
if strings.HasPrefix(bucket, "arn:") {
return bucket
}
if bucket == "" {
return "arn:aws:s3:::*"
}

22
weed/s3api/s3api_embedded_iam.go

@ -1003,16 +1003,20 @@ func (e *EmbeddedIamApi) AuthIam(f http.HandlerFunc, _ Action) http.HandlerFunc
f(w, r)
return
}
// Operating on another user: require admin
// Operating on another user: require admin or permission
if !identity.isAdmin() {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
if e.iam.VerifyActionPermission(r, identity, Action("iam:"+action), "arn:aws:iam:::*", "") != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
}
} else {
// All other IAM actions require admin (CreateUser, DeleteUser, PutUserPolicy, etc.)
// All other IAM actions require admin or permission
if !identity.isAdmin() {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
if e.iam.VerifyActionPermission(r, identity, Action("iam:"+action), "arn:aws:iam:::*", "") != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
}
}
@ -1094,8 +1098,10 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// CreatePolicy only validates the policy document and returns metadata.
// Policies are not stored separately; they are attached inline via PutUserPolicy.
case "DeletePolicy":
// Managed policies are not stored separately, so deletion is a no-op.
// Returns success for AWS compatibility.
response = struct{}{}
changed = false
case "PutUserPolicy":
response, iamErr = e.PutUserPolicy(s3cfg, values)

Loading…
Cancel
Save