Files
seaweedfs/weed/s3api/s3api_sts_assume_role_test.go
T
Chris LuandDevin 2864bc0fe8 s3: honor configured session bounds on AssumeRole and LDAP identity (#11478)
* sts: export CalculateSessionDuration

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: honor configured session bounds on AssumeRole and LDAP identity

prepareSTSCredentials hardcoded a one-hour session when the caller
omitted DurationSeconds, so sts.tokenDuration was ignored and
sts.maxSessionLength only clamped explicit requests: asking for 3600s
against a 20m ceiling was rejected while omitting the parameter was
granted a full hour (#11473). The two affected handlers now use the
same default-then-cap calculation as AssumeRoleWithWebIdentity.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iam: keep MaxSessionDuration through role store copies

copyRoleDefinition rebuilt RoleDefinition field by field and dropped
MaxSessionDuration, so memory-backed role stores silently discarded the
per-role session bound on every write and read (devin on #11478).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* sts: apply per-role MaxSessionDuration to resolved session durations

Review follow-up on #11478 (devin): the role bound only ever applied to
explicit DurationSeconds values — an omitted duration resolved to the
configured default and sailed past a shorter role max on every assume
path.

- capDurationByRole now resolves min(requested||tokenDuration, roleMax),
  so AssumeRoleWithWebIdentity and AssumeRoleWithCredentials cap
  defaults the same way they cap explicit values.
- prepareSTSCredentials caps the calculated duration at the named
  role's MaxSessionDuration, covering the AssumeRole and LDAP handlers;
  self-assumption has no role definition to consult.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iam: keep MaxSessionDuration through the cached role store

genericCopyRoleDefinition drops MaxSessionDuration the same way
copyRoleDefinition did, so the cached filer role store reads back a zero
maximum and every downstream duration cap is skipped (greptile on
#11478).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* sts: only materialize defaults that pass session duration validation

Review follow-up on #11478 (greptile): materializing an omitted
DurationSeconds into an explicit value could exceed the service's own
input bound (a configured tokenDuration above maxSessionLength) and turn
a previously working request into a validation error.

capDurationByRole now leaves nil anything the service can resolve
better itself, clamps a tightened default at maxSessionLengthSeconds,
and floors a role bound below 900s to the tightest issuable value.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 07:01:51 +08:00

305 lines
11 KiB
Go

package s3api
import (
"context"
"fmt"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAssumeRole_CallerIdentityFallback tests the fallback logic when RoleArn is missing
func TestAssumeRole_CallerIdentityFallback(t *testing.T) {
// Setup STS service
stsService, _ := setupTestSTSService(t)
// Create IAM integration mock
iamMock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
// Allow global sts:AssumeRole
if action == "sts:AssumeRole" {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
},
validateTrustPolicyFunc: func(ctx context.Context, roleArn, principalArn string) error {
// Allow all trust policies for this test
return nil
},
}
// Create IAM service with the mock integration
iam := &IdentityAccessManagement{
iamIntegration: iamMock,
}
// Create STS handlers
stsHandlers := NewSTSHandlers(stsService, iam)
// Test case 1: Caller is an IAM User, RoleArn is missing
t.Run("Caller is IAM User, No RoleArn", func(t *testing.T) {
// Mock request
req, err := http.NewRequest("POST", "/", nil)
require.NoError(t, err)
req.Form = url.Values{}
req.Form.Set("Action", "AssumeRole")
req.Form.Set("RoleSessionName", "test-session")
req.Form.Set("Version", "2011-06-15")
// Mock the authenticated identity (IAM User)
callerIdentity := &Identity{
Name: "alice",
Account: &AccountAdmin,
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/alice", defaultAccountID),
Actions: []Action{s3_constants.ACTION_ADMIN},
}
// 1. Test prepareSTSCredentials with NO RoleArn (simulating the fallback logic having passed PrincipalArn)
// expected RoleArn passed to prepareSTSCredentials would be the caller's PrincipalArn
fallbackRoleArn := callerIdentity.PrincipalArn
// Prepare custom claims for the session (mimicking handleAssumeRole logic)
var modifyClaims func(claims *sts.STSSessionClaims)
if callerIdentity.isAdmin() {
modifyClaims = func(claims *sts.STSSessionClaims) {
if claims.RequestContext == nil {
claims.RequestContext = make(map[string]interface{})
}
claims.RequestContext["is_admin"] = true
}
}
stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), fallbackRoleArn, "test-session", nil, "", modifyClaims)
require.NoError(t, err)
// Assertions
// The role name should be extracted from the user ARN ("alice")
assert.Contains(t, assumedUser.Arn, fmt.Sprintf("assumed-role/alice/test-session"))
assert.Contains(t, assumedUser.AssumedRoleId, "alice:test-session")
// Verify token claims using ValidateSessionToken
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), stsCreds.SessionToken)
require.NoError(t, err)
// The RoleArn in session info should match the fallback ARN (user ARN)
assert.Equal(t, fallbackRoleArn, sessionInfo.RoleArn)
// Verify is_admin claim is present
isAdmin, ok := sessionInfo.RequestContext["is_admin"].(bool)
assert.True(t, ok, "is_admin claim should be present")
assert.True(t, isAdmin, "is_admin claim should be true")
})
// Test case 2: Caller is an STS Assumed Role, No RoleArn
t.Run("Caller is STS Assumed Role, No RoleArn", func(t *testing.T) {
// Mock identity
callerIdentity := &Identity{
Name: "arn:aws:sts::111122223333:assumed-role/admin/session1",
Account: &AccountAdmin,
PrincipalArn: "arn:aws:sts::111122223333:assumed-role/admin/session1",
}
fallbackRoleArn := callerIdentity.PrincipalArn
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")
assert.Contains(t, assumedUser.Arn, "assumed-role/admin/nested-session")
assert.Contains(t, assumedUser.AssumedRoleId, "admin:nested-session")
// Check claims
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), stsCreds.SessionToken)
require.NoError(t, err)
assert.Equal(t, fallbackRoleArn, sessionInfo.RoleArn)
})
// Test case 3: Explicit RoleArn provided (Standard AssumeRole)
t.Run("Explicit RoleArn Provided", func(t *testing.T) {
explicitRoleArn := "arn:aws:iam::111122223333:role/TargetRole"
stsCreds, assumedUser, err := stsHandlers.prepareSTSCredentials(context.Background(), explicitRoleArn, "explicit-session", nil, "", nil)
require.NoError(t, err)
// Role name should be "TargetRole"
assert.Contains(t, assumedUser.Arn, "assumed-role/TargetRole/explicit-session")
// Check claims
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), stsCreds.SessionToken)
require.NoError(t, err)
assert.Equal(t, explicitRoleArn, sessionInfo.RoleArn)
})
// Test case 4: Malformed ARN (Edge case)
t.Run("Malformed ARN", func(t *testing.T) {
malformedArn := "invalid-arn"
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
assert.Contains(t, assumedUser.Arn, "assumed-role/invalid-arn/bad-session")
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), stsCreds.SessionToken)
require.NoError(t, err)
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
}
// AssumeRole derives its session length from the same default-then-cap rule as
// the service layer (#11473): an omitted DurationSeconds yields TokenDuration,
// and either path is capped at MaxSessionLength.
func TestPrepareSTSCredentialsHonorsConfiguredDurations(t *testing.T) {
stsService := sts.NewSTSService()
require.NoError(t, stsService.Initialize(&sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: 15 * time.Minute},
MaxSessionLength: sts.FlexibleDuration{Duration: 20 * time.Minute},
Issuer: "test-issuer",
SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"),
}))
stsHandlers := NewSTSHandlers(stsService, nil)
roleArn := fmt.Sprintf("arn:aws:iam::%s:role/test-role", defaultAccountID)
expiresIn := func(durationSeconds *int64) time.Duration {
stsCreds, _, err := stsHandlers.prepareSTSCredentials(context.Background(), roleArn, "test-session", durationSeconds, "", nil)
require.NoError(t, err)
exp, err := time.Parse(time.RFC3339, stsCreds.Expiration)
require.NoError(t, err)
return time.Until(exp)
}
oneHour := int64(3600)
assert.InDelta(t, (15 * time.Minute).Seconds(), expiresIn(nil).Seconds(), 60)
assert.InDelta(t, (20 * time.Minute).Seconds(), expiresIn(&oneHour).Seconds(), 60)
}
// A named role's MaxSessionDuration bounds the session however DurationSeconds
// was resolved, matching the SDK paths' capDurationByRole.
func TestPrepareSTSCredentialsCapsAtRoleMaxDuration(t *testing.T) {
ctx := context.Background()
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.CreateRole(ctx, "", "ShortLivedRole", &integration.RoleDefinition{
RoleName: "ShortLivedRole",
MaxSessionDuration: 3600,
}))
stsService := sts.NewSTSService()
require.NoError(t, stsService.Initialize(&sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: 2 * time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-issuer",
SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"),
}))
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
stsHandlers := NewSTSHandlers(stsService, iam)
roleArn := fmt.Sprintf("arn:aws:iam::%s:role/ShortLivedRole", defaultAccountID)
expiresIn := func(durationSeconds *int64) time.Duration {
stsCreds, _, err := stsHandlers.prepareSTSCredentials(ctx, roleArn, "test-session", durationSeconds, "", nil)
require.NoError(t, err)
exp, err := time.Parse(time.RFC3339, stsCreds.Expiration)
require.NoError(t, err)
return time.Until(exp)
}
twoHours := int64(7200)
assert.InDelta(t, float64(3600), expiresIn(nil).Seconds(), 60, "omitted duration resolves to the 2h default but the role caps it at 1h")
assert.InDelta(t, float64(3600), expiresIn(&twoHours).Seconds(), 60, "explicit duration above the role max is capped")
}