mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
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.
233 lines
7.8 KiB
Go
233 lines
7.8 KiB
Go
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
|
|
}
|