diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index bc198a56c..ba48c3e08 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -248,6 +248,18 @@ func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleNa return m.roleStore.StoreRole(ctx, "", roleName, roleDef) } +// GetRole retrieves a role definition by name. +func (m *IAMManager) GetRole(ctx context.Context, roleName string) (*RoleDefinition, error) { + if !m.initialized { + return nil, fmt.Errorf("IAM manager not initialized") + } + if roleName == "" { + return nil, fmt.Errorf("role name cannot be empty") + } + + return m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) +} + // UpdateBucketPolicy updates the policy for a bucket func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) error { if !m.initialized { diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 3a31a7404..e5ae898ee 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -47,6 +47,11 @@ type IAMIntegration interface { DefaultAllow() bool } +// IAMManagerProvider exposes the IAMManager backing an IAM integration. +type IAMManagerProvider interface { + GetIAMManager() *integration.IAMManager +} + // S3IAMIntegration provides IAM integration for S3 API type S3IAMIntegration struct { iamManager *integration.IAMManager @@ -70,6 +75,11 @@ func NewS3IAMIntegration(iamManager *integration.IAMManager, filerAddress string } } +// GetIAMManager returns the IAMManager backing this integration. +func (s3iam *S3IAMIntegration) GetIAMManager() *integration.IAMManager { + return s3iam.iamManager +} + // AuthenticateJWT authenticates JWT tokens using our STS service func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) { diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go index c51168483..780a39141 100644 --- a/weed/s3api/s3api_sts.go +++ b/weed/s3api/s3api_sts.go @@ -5,6 +5,7 @@ package s3api // AWS SDKs to obtain temporary credentials using OIDC/JWT tokens. import ( + "context" "encoding/xml" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" "github.com/seaweedfs/seaweedfs/weed/iam/ldap" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/iam/utils" @@ -339,7 +341,7 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { } // Generate common STS components - stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims) + stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims) if err != nil { h.writeSTSErrorResponse(w, r, STSErrInternalError, err) return @@ -480,7 +482,7 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider) } - stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims) + stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims) if err != nil { h.writeSTSErrorResponse(w, r, STSErrInternalError, err) return @@ -499,7 +501,7 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r } // prepareSTSCredentials extracts common shared logic for credential generation -func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string, +func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, roleSessionName string, durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) { // Calculate duration @@ -546,6 +548,33 @@ func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string, WithSessionName(roleSessionName). WithRoleInfo(effectiveRoleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn) + // If IAM integration is available, embed the role's attached policies into the session token. + // This makes the token self-sufficient for authorization even when role lookup is unavailable. + var policyManager *integration.IAMManager + if h.iam != nil && h.iam.iamIntegration != nil { + if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok { + policyManager = provider.GetIAMManager() + } + } + + if policyManager != nil { + roleNameForPolicies := utils.ExtractRoleNameFromArn(roleArn) + if roleNameForPolicies == "" { + roleNameForPolicies = utils.ExtractRoleNameFromPrincipal(roleArn) + } + + if roleNameForPolicies != "" && len(claims.Policies) == 0 { + roleDef, err := policyManager.GetRole(ctx, roleNameForPolicies) + if err != nil { + glog.V(2).Infof("Failed to load role %q for policy embedding: %v", roleNameForPolicies, err) + } else if roleDef == nil { + glog.V(2).Infof("Role definition %q was missing for policy embedding", roleNameForPolicies) + } else if len(roleDef.AttachedPolicies) > 0 { + claims.WithPolicies(roleDef.AttachedPolicies) + } + } + } + if sessionPolicy != "" { claims.WithSessionPolicy(sessionPolicy) } diff --git a/weed/s3api/s3api_sts_assume_role_test.go b/weed/s3api/s3api_sts_assume_role_test.go index 74890f74b..27e74a79d 100644 --- a/weed/s3api/s3api_sts_assume_role_test.go +++ b/weed/s3api/s3api_sts_assume_role_test.go @@ -6,7 +6,10 @@ import ( "net/http" "net/url" "testing" + "time" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" @@ -75,7 +78,7 @@ func TestAssumeRole_CallerIdentityFallback(t *testing.T) { } } - stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(fallbackRoleArn, "test-session", nil, "", modifyClaims) + stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), fallbackRoleArn, "test-session", nil, "", modifyClaims) require.NoError(t, err) // Assertions @@ -107,7 +110,7 @@ func TestAssumeRole_CallerIdentityFallback(t *testing.T) { fallbackRoleArn := callerIdentity.PrincipalArn - stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(fallbackRoleArn, "nested-session", nil, "", nil) + stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), fallbackRoleArn, "nested-session", nil, "", nil) require.NoError(t, err) // The role name should be extracted from the assumed role ARN ("admin") @@ -124,7 +127,7 @@ func TestAssumeRole_CallerIdentityFallback(t *testing.T) { t.Run("Explicit RoleArn Provided", func(t *testing.T) { explicitRoleArn := "arn:aws:iam::111122223333:role/TargetRole" - stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(explicitRoleArn, "explicit-session", nil, "", nil) + stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), explicitRoleArn, "explicit-session", nil, "", nil) require.NoError(t, err) // Role name should be "TargetRole" @@ -140,7 +143,7 @@ func TestAssumeRole_CallerIdentityFallback(t *testing.T) { t.Run("Malformed ARN", func(t *testing.T) { malformedArn := "invalid-arn" - stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(malformedArn, "bad-session", nil, "", nil) + stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), malformedArn, "bad-session", nil, "", nil) require.NoError(t, err) // Fallback behavior: use full string as role name if extraction fails @@ -151,3 +154,90 @@ func TestAssumeRole_CallerIdentityFallback(t *testing.T) { assert.Equal(t, malformedArn, sessionInfo.RoleArn) }) } + +func TestAssumeRole_EmbedsRolePolicies(t *testing.T) { + t.Run("RoleWithAttachedPolicies", func(t *testing.T) { + ctx := context.Background() + manager := newTestSTSIntegrationManager(t) + + writePolicy := &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{ + { + Effect: "Allow", + Action: []string{"s3:*"}, + Resource: []string{ + "arn:aws:s3:::*", + "arn:aws:s3:::*/*", + }, + }, + }, + } + require.NoError(t, manager.CreatePolicy(ctx, "", "S3WritePolicy", writePolicy)) + + roleName := "LakekeeperVendedRole" + require.NoError(t, manager.CreateRole(ctx, "", roleName, &integration.RoleDefinition{ + RoleName: roleName, + AttachedPolicies: []string{"S3WritePolicy"}, + })) + + iam := &IdentityAccessManagement{ + iamIntegration: NewS3IAMIntegration(manager, ""), + } + stsHandlers := NewSTSHandlers(manager.GetSTSService(), iam) + + roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", defaultAccountID, roleName) + stsCreds, _, err := stsHandlers.prepareSTSCredentials(ctx, roleArn, "test-session", nil, "", nil) + require.NoError(t, err) + + sessionInfo, err := manager.GetSTSService().ValidateSessionToken(ctx, stsCreds.SessionToken) + require.NoError(t, err) + require.NotNil(t, sessionInfo) + assert.Equal(t, []string{"S3WritePolicy"}, sessionInfo.Policies) + }) + + t.Run("RoleWithoutAttachedPolicies", func(t *testing.T) { + ctx := context.Background() + manager := newTestSTSIntegrationManager(t) + + roleName := "LakekeeperEmptyRole" + require.NoError(t, manager.CreateRole(ctx, "", roleName, &integration.RoleDefinition{ + RoleName: roleName, + })) + + iam := &IdentityAccessManagement{ + iamIntegration: NewS3IAMIntegration(manager, ""), + } + stsHandlers := NewSTSHandlers(manager.GetSTSService(), iam) + + roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", defaultAccountID, roleName) + stsCreds, _, err := stsHandlers.prepareSTSCredentials(ctx, roleArn, "test-session", nil, "", nil) + require.NoError(t, err) + + sessionInfo, err := manager.GetSTSService().ValidateSessionToken(ctx, stsCreds.SessionToken) + require.NoError(t, err) + assert.Empty(t, sessionInfo.Policies) + }) +} + +func newTestSTSIntegrationManager(t *testing.T) *integration.IAMManager { + t.Helper() + manager := integration.NewIAMManager() + config := &integration.IAMConfig{ + STS: &sts.STSConfig{ + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour}, + Issuer: "test-issuer", + SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"), + }, + Policy: &policy.PolicyEngineConfig{ + DefaultEffect: "Deny", + StoreType: "memory", + }, + Roles: &integration.RoleStoreConfig{ + StoreType: "memory", + }, + } + require.NoError(t, manager.Initialize(config, func() string { return "" })) + return manager +}