diff --git a/weed/iam/oidc/mock_provider.go b/weed/iam/oidc/mock_provider.go index c4ff9a401..3d5f0edab 100644 --- a/weed/iam/oidc/mock_provider.go +++ b/weed/iam/oidc/mock_provider.go @@ -59,12 +59,14 @@ func (m *MockOIDCProvider) Authenticate(ctx context.Context, token string) (*pro email, _ := claims.GetClaimString("email") displayName, _ := claims.GetClaimString("name") groups, _ := claims.GetClaimStringSlice("groups") + rawRoles, _ := claims.GetClaimStringSlice("roles") return &providers.ExternalIdentity{ UserID: claims.Subject, Email: email, DisplayName: displayName, Groups: groups, + Roles: rawRoles, Provider: m.name, }, nil } @@ -107,16 +109,26 @@ func (m *MockOIDCProvider) ValidateToken(ctx context.Context, token string) (*pr issuedAt = time.Unix(int64(iat), 0) } + // Carry the token's claims through like the real provider, keeping + // the synthesized defaults for tests that rely on them + claimsMap := make(map[string]interface{}, len(jwtClaims)+2) + for k, v := range jwtClaims { + claimsMap[k] = v + } + if _, ok := claimsMap["email"]; !ok { + claimsMap["email"] = subject + "@test-domain.com" + } + if _, ok := claimsMap["name"]; !ok { + claimsMap["name"] = "Test User " + subject + } + return &providers.TokenClaims{ Subject: subject, Issuer: issuer, Audience: audience, ExpiresAt: expiresAt, IssuedAt: issuedAt, - Claims: map[string]interface{}{ - "email": subject + "@test-domain.com", - "name": "Test User " + subject, - }, + Claims: claimsMap, }, nil } } diff --git a/weed/s3api/s3_iam_group_role_abac_test.go b/weed/s3api/s3_iam_group_role_abac_test.go new file mode 100644 index 000000000..c3ea4244d --- /dev/null +++ b/weed/s3api/s3_iam_group_role_abac_test.go @@ -0,0 +1,232 @@ +package s3api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/iam/oidc" + "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" +) + +// TestGroupsRolesPolicyEnforcement verifies that OIDC groups and roles claims reach +// the request-time policy evaluation of an STS session, so a single role's permission +// policy can scope bucket access with ForAnyValue:StringEquals on jwt:groups / jwt:roles. +// The full path is exercised: AssumeRoleWithWebIdentity embeds the claims in the session +// JWT, AuthenticateJWT restores them (as []interface{} after the JSON round-trip), and +// AuthorizeAction evaluates the conditions. +func TestGroupsRolesPolicyEnforcement(t *testing.T) { + ctx := context.Background() + iamManager := setupGroupsRolesIAMManager(t) + s3iam := NewS3IAMIntegration(iamManager, "localhost:8888") + + // One role for everyone; each bucket statement is gated on a groups or roles claim + abacPolicy := &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{ + { + Sid: "TeamABucketByGroup", + Effect: "Allow", + Action: []string{"s3:GetObject", "s3:PutObject"}, + Resource: []string{ + "arn:aws:s3:::team-a-bucket/*", + }, + Condition: map[string]map[string]interface{}{ + "ForAnyValue:StringEquals": { + "jwt:groups": "team-a", + }, + }, + }, + { + Sid: "TeamBBucketByRole", + Effect: "Allow", + Action: []string{"s3:GetObject", "s3:PutObject"}, + Resource: []string{ + "arn:aws:s3:::team-b-bucket/*", + }, + Condition: map[string]map[string]interface{}{ + "ForAnyValue:StringEquals": { + "jwt:roles": "team-b-rw", + }, + }, + }, + }, + } + require.NoError(t, iamManager.CreatePolicy(ctx, "", "TeamABACPolicy", abacPolicy)) + require.NoError(t, iamManager.CreateRole(ctx, "", "TeamABACRole", &integration.RoleDefinition{ + RoleName: "TeamABACRole", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{ + { + Effect: "Allow", + Principal: map[string]interface{}{ + "Federated": "test-oidc", + }, + Action: []string{"sts:AssumeRoleWithWebIdentity"}, + }, + }, + }, + AttachedPolicies: []string{"TeamABACPolicy"}, + })) + + tests := []struct { + name string + token string + allowTeamA bool + allowTeamB bool + }{ + { + name: "group member gets team-a bucket only", + token: groupsRolesJWT(t, "team-a-user", []string{"team-a"}, nil), + allowTeamA: true, + allowTeamB: false, + }, + { + name: "role holder gets team-b bucket only", + token: groupsRolesJWT(t, "team-b-user", nil, []string{"team-b-rw"}), + allowTeamA: false, + allowTeamB: true, + }, + { + name: "member of both gets both buckets in one session", + token: groupsRolesJWT(t, "both-teams-user", []string{"team-a", "team-b"}, []string{"team-a-rw", "team-b-rw"}), + allowTeamA: true, + allowTeamB: true, + }, + { + name: "user without matching claims is denied everywhere", + token: groupsRolesJWT(t, "no-team-user", []string{"other-team"}, []string{"other-role"}), + allowTeamA: false, + allowTeamB: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{ + RoleArn: "arn:aws:iam::role/TeamABACRole", + WebIdentityToken: tt.token, + RoleSessionName: "abac-test-session", + }) + require.NoError(t, err) + + identity, errCode := authenticateSessionToken(t, s3iam, response.Credentials.SessionToken) + require.Equal(t, s3err.ErrNone, errCode) + + assert.Equal(t, tt.allowTeamA, authorizeGet(ctx, s3iam, identity, "team-a-bucket"), "team-a-bucket access") + assert.Equal(t, tt.allowTeamB, authorizeGet(ctx, s3iam, identity, "team-b-bucket"), "team-b-bucket access") + }) + } +} + +// TestGroupsRolesSessionRequestContext pins the session round-trip itself: the groups +// and roles claims must come back from the session JWT as multi-valued context keys. +func TestGroupsRolesSessionRequestContext(t *testing.T) { + ctx := context.Background() + iamManager := setupGroupsRolesIAMManager(t) + + require.NoError(t, iamManager.CreateRole(ctx, "", "ContextRole", &integration.RoleDefinition{ + RoleName: "ContextRole", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{ + { + Effect: "Allow", + Principal: map[string]interface{}{ + "Federated": "test-oidc", + }, + Action: []string{"sts:AssumeRoleWithWebIdentity"}, + }, + }, + }, + })) + + response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{ + RoleArn: "arn:aws:iam::role/ContextRole", + WebIdentityToken: groupsRolesJWT(t, "both-teams-user", []string{"team-a", "team-b"}, []string{"team-a-rw", "team-b-rw"}), + RoleSessionName: "context-test-session", + }) + require.NoError(t, err) + + sessionInfo, err := iamManager.GetSTSService().ValidateSessionToken(ctx, response.Credentials.SessionToken) + require.NoError(t, err) + require.NotNil(t, sessionInfo.RequestContext) + + // JWT serialization turns []string into []interface{}; assert that shape so a + // future change to the condition evaluator's type handling fails loudly here. + assert.Equal(t, []interface{}{"team-a", "team-b"}, sessionInfo.RequestContext["groups"]) + assert.Equal(t, []interface{}{"team-a-rw", "team-b-rw"}, sessionInfo.RequestContext["roles"]) +} + +func setupGroupsRolesIAMManager(t *testing.T) *integration.IAMManager { + manager := integration.NewIAMManager() + config := &integration.IAMConfig{ + STS: &sts.STSConfig{ + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, + Issuer: "test-sts", + SigningKey: []byte("test-signing-key-32-characters-long"), + }, + Policy: &policy.PolicyEngineConfig{ + DefaultEffect: "Deny", + StoreType: "memory", + }, + Roles: &integration.RoleStoreConfig{ + StoreType: "memory", + }, + } + require.NoError(t, manager.Initialize(config, func() string { + return "localhost:8888" + })) + + provider := oidc.NewMockOIDCProvider("test-oidc") + require.NoError(t, provider.Initialize(&oidc.OIDCConfig{ + Issuer: "https://test-issuer.com", + ClientID: "test-client-id", + })) + + require.NoError(t, manager.RegisterIdentityProvider(provider)) + return manager +} + +// groupsRolesJWT builds a web identity token carrying groups and roles claims. +func groupsRolesJWT(t *testing.T, subject string, groups, roles []string) string { + claims := jwt.MapClaims{ + "iss": "https://test-issuer.com", + "sub": subject, + "aud": "test-client-id", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + if groups != nil { + claims["groups"] = groups + } + if roles != nil { + claims["roles"] = roles + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-signing-key")) + require.NoError(t, err) + return token +} + +func authenticateSessionToken(t *testing.T, s3iam *S3IAMIntegration, sessionToken string) (*IAMIdentity, s3err.ErrorCode) { + req := httptest.NewRequest("GET", "/", http.NoBody) + req.Header.Set("Authorization", "Bearer "+sessionToken) + return s3iam.AuthenticateJWT(context.Background(), req) +} + +func authorizeGet(ctx context.Context, s3iam *S3IAMIntegration, identity *IAMIdentity, bucket string) bool { + req := httptest.NewRequest("GET", "/"+bucket+"/file.txt", http.NoBody) + req.Header.Set("Authorization", "Bearer "+identity.SessionToken) + return s3iam.AuthorizeAction(ctx, identity, s3_constants.ACTION_READ, bucket, "file.txt", req) == s3err.ErrNone +}