mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 06:54:24 +00:00
iam: test that groups and roles claims reach request-time policy evaluation
Drives the full path: AssumeRoleWithWebIdentity embeds the claims in the session JWT, AuthenticateJWT restores them, and AuthorizeAction evaluates ForAnyValue:StringEquals on jwt:groups / jwt:roles per bucket. The mock OIDC provider now carries token claims through and surfaces roles, matching the real provider.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user