Add session policy support to IAM (#8338)
* Add session policy support to IAM - Implement policy evaluation for session tokens in policy_engine.go - Add session_policy field to session claims for tracking applied policies - Update STS service to include session policies in token generation - Add IAM integration tests for session policy validation - Update IAM manager to support policy attachment to sessions - Extend S3 API STS endpoint to handle session policy restrictions * fix: optimize session policy evaluation and add documentation * sts: add NormalizeSessionPolicy helper for inline session policies * sts: support inline session policies for AssumeRoleWithWebIdentity and credential-based flows * s3api: parse and normalize Policy parameter for STS HTTP handlers * tests: add session policy unit tests and integration tests for inline policy downscoping * tests: add s3tables STS inline policy integration * iam: handle user principals and validate tokens * sts: enforce inline session policy size limit * tests: harden s3tables STS integration config * iam: clarify principal policy resolution errors * tests: improve STS integration endpoint selection
This commit is contained in:
@@ -251,6 +251,132 @@ func TestPolicyEnforcement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionPolicyBoundary verifies that inline session policies restrict permissions.
|
||||
func TestSessionPolicyBoundary(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stsService := iamManager.GetSTSService()
|
||||
require.NotNil(t, stsService)
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
principal := "arn:aws:sts::000000000000:assumed-role/S3ReadOnlyRole/policy-session"
|
||||
|
||||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiresAt).
|
||||
WithSessionName("policy-session").
|
||||
WithRoleInfo("arn:aws:iam::role/S3ReadOnlyRole", principal, principal).
|
||||
WithSessionPolicy(sessionPolicy)
|
||||
|
||||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny ListBucket when not explicitly allowed")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentitySessionPolicy verifies Policy downscoping is applied to web identity sessions.
|
||||
func TestAssumeRoleWithWebIdentitySessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-web-identity",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentialsSessionPolicy verifies Policy downscoping is applied to credentials sessions.
|
||||
func TestAssumeRoleWithCredentialsSessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["filer:CreateEntry"],"Resource":["arn:aws:filer::path/user-docs/allowed/*"]}]}`
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "policy-ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow CreateEntry within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny CreateEntry outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestSessionExpiration tests session expiration and cleanup
|
||||
func TestSessionExpiration(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
// maxPoliciesForEvaluation defines an upper bound on the number of policies that
|
||||
@@ -23,6 +25,7 @@ type IAMManager struct {
|
||||
stsService *sts.STSService
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
}
|
||||
@@ -48,6 +51,11 @@ type RoleStoreConfig struct {
|
||||
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
||||
}
|
||||
|
||||
// UserStore defines the interface for retrieving IAM user policy attachments.
|
||||
type UserStore interface {
|
||||
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
|
||||
}
|
||||
|
||||
// RoleDefinition defines a role with its trust policy and attached policies
|
||||
type RoleDefinition struct {
|
||||
// RoleName is the name of the role
|
||||
@@ -92,6 +100,11 @@ func NewIAMManager() *IAMManager {
|
||||
return &IAMManager{}
|
||||
}
|
||||
|
||||
// SetUserStore assigns the user store used to resolve IAM user policy attachments.
|
||||
func (m *IAMManager) SetUserStore(store UserStore) {
|
||||
m.userStore = store
|
||||
}
|
||||
|
||||
// Initialize initializes the IAM manager with all components
|
||||
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
|
||||
if config == nil {
|
||||
@@ -312,8 +325,10 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
// Validate session token if present (skip for OIDC tokens which are already validated,
|
||||
// and skip for empty tokens which represent static access keys)
|
||||
var sessionInfo *sts.SessionInfo
|
||||
if request.SessionToken != "" && !isOIDCToken(request.SessionToken) {
|
||||
_, err := m.stsService.ValidateSessionToken(ctx, request.SessionToken)
|
||||
var err error
|
||||
sessionInfo, err = m.stsService.ValidateSessionToken(ctx, request.SessionToken)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid session: %w", err)
|
||||
}
|
||||
@@ -349,6 +364,9 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
evalCtx.RequestContext["aws:username"] = awsUsername
|
||||
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName
|
||||
} else if userName := utils.ExtractUserNameFromPrincipal(request.Principal); userName != "" {
|
||||
evalCtx.RequestContext["aws:username"] = userName
|
||||
evalCtx.RequestContext["aws:userid"] = userName
|
||||
}
|
||||
if arnInfo.AccountID != "" {
|
||||
evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID
|
||||
@@ -364,58 +382,75 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
}
|
||||
}
|
||||
|
||||
// If explicit policy names are provided (e.g. from user identity), evaluate them directly
|
||||
if len(request.PolicyNames) > 0 {
|
||||
policies := request.PolicyNames
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
policies := request.PolicyNames
|
||||
if len(policies) == 0 {
|
||||
// Extract role name from principal ARN
|
||||
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
|
||||
if roleName == "" {
|
||||
userName := utils.ExtractUserNameFromPrincipal(request.Principal)
|
||||
if userName == "" {
|
||||
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
|
||||
}
|
||||
// Create a new slice to avoid modifying the request and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
if m.userStore == nil {
|
||||
return false, fmt.Errorf("user store unavailable for principal: %s", request.Principal)
|
||||
}
|
||||
user, err := m.userStore.GetUser(ctx, userName)
|
||||
if err != nil || user == nil {
|
||||
return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName)
|
||||
}
|
||||
policies = user.GetPolicyNames()
|
||||
} else {
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
policies = roleDef.AttachedPolicies
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
return result.Effect == policy.EffectAllow, nil
|
||||
}
|
||||
|
||||
// Extract role name from principal ARN
|
||||
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
|
||||
if roleName == "" {
|
||||
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
|
||||
}
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
// Evaluate policies attached to the role
|
||||
policies := roleDef.AttachedPolicies
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
}
|
||||
// Create a new slice to avoid modifying the role definition and append the bucket policy
|
||||
// Create a new slice to avoid modifying the original and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
baseResult, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Effect == policy.EffectAllow, nil
|
||||
// Base policy must allow; if it doesn't, deny immediately (session policy can only further restrict)
|
||||
if baseResult.Effect != policy.EffectAllow {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If there's a session policy, it must also allow the action
|
||||
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
|
||||
var sessionPolicy policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil {
|
||||
return false, fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&sessionPolicy); err != nil {
|
||||
return false, fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
sessionResult, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("session policy evaluation failed: %w", err)
|
||||
}
|
||||
if sessionResult.Effect != policy.EffectAllow {
|
||||
// Session policy does not allow this action
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ValidateTrustPolicy validates if a principal can assume a role (for testing)
|
||||
@@ -643,7 +678,28 @@ func isOIDCToken(token string) bool {
|
||||
}
|
||||
|
||||
// JWT tokens typically start with "eyJ" (base64 encoded JSON starting with "{")
|
||||
return strings.HasPrefix(token, "eyJ")
|
||||
if !strings.HasPrefix(token, "eyJ") {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if typ, ok := claims["typ"].(string); ok && typ == sts.TokenTypeSession {
|
||||
return false
|
||||
}
|
||||
if typ, ok := claims[sts.JWTClaimTokenType].(string); ok && typ == sts.TokenTypeSession {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TrustPolicyValidator interface implementation
|
||||
|
||||
@@ -474,6 +474,68 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EvaluatePolicyDocument evaluates a single policy document without storing it.
|
||||
// defaultEffect controls the fallback result when no statements match.
|
||||
func (e *PolicyEngine) EvaluatePolicyDocument(ctx context.Context, evalCtx *EvaluationContext, policyName string, policyDoc *PolicyDocument, defaultEffect Effect) (*EvaluationResult, error) {
|
||||
if !e.initialized {
|
||||
return nil, fmt.Errorf("policy engine not initialized")
|
||||
}
|
||||
|
||||
if evalCtx == nil {
|
||||
return nil, fmt.Errorf("evaluation context cannot be nil")
|
||||
}
|
||||
|
||||
if policyDoc == nil {
|
||||
return nil, fmt.Errorf("policy document cannot be nil")
|
||||
}
|
||||
|
||||
if policyName == "" {
|
||||
policyName = "inline-policy"
|
||||
}
|
||||
|
||||
result := &EvaluationResult{
|
||||
Effect: defaultEffect,
|
||||
EvaluationDetails: &EvaluationDetails{
|
||||
Principal: evalCtx.Principal,
|
||||
Action: evalCtx.Action,
|
||||
Resource: evalCtx.Resource,
|
||||
PoliciesEvaluated: []string{policyName},
|
||||
},
|
||||
}
|
||||
|
||||
var matchingStatements []StatementMatch
|
||||
explicitDeny := false
|
||||
hasAllow := false
|
||||
|
||||
for _, statement := range policyDoc.Statement {
|
||||
if e.statementMatches(&statement, evalCtx) {
|
||||
match := StatementMatch{
|
||||
PolicyName: policyName,
|
||||
StatementSid: statement.Sid,
|
||||
Effect: Effect(statement.Effect),
|
||||
Reason: "Action, Resource, and Condition matched",
|
||||
}
|
||||
matchingStatements = append(matchingStatements, match)
|
||||
|
||||
if statement.Effect == "Deny" {
|
||||
explicitDeny = true
|
||||
} else if statement.Effect == "Allow" {
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.MatchingStatements = matchingStatements
|
||||
|
||||
if explicitDeny {
|
||||
result.Effect = EffectDeny
|
||||
} else if hasAllow {
|
||||
result.Effect = EffectAllow
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// statementMatches checks if a statement matches the evaluation context
|
||||
func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool {
|
||||
// Check principal match (for trust policies)
|
||||
|
||||
@@ -31,6 +31,8 @@ type STSSessionClaims struct {
|
||||
|
||||
// Authorization data
|
||||
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
|
||||
// SessionPolicy contains inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"spol,omitempty"`
|
||||
|
||||
// Identity provider information
|
||||
IdentityProvider string `json:"idp"` // identity_provider
|
||||
@@ -88,6 +90,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
AssumedRoleUser: c.AssumedRole,
|
||||
Principal: c.Principal,
|
||||
Policies: c.Policies,
|
||||
SessionPolicy: c.SessionPolicy,
|
||||
ExpiresAt: expiresAt,
|
||||
IdentityProvider: c.IdentityProvider,
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
@@ -148,6 +151,12 @@ func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionPolicy sets the inline session policy JSON for this session
|
||||
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
|
||||
c.SessionPolicy = policy
|
||||
return c
|
||||
}
|
||||
|
||||
// WithIdentityProvider sets identity provider information
|
||||
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
|
||||
c.IdentityProvider = providerName
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
expiresAt := time.Now().Add(2 * time.Hour)
|
||||
|
||||
policies := []string{"policy1", "policy2"}
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
|
||||
requestContext := map[string]interface{}{
|
||||
"sourceIp": "192.168.1.1",
|
||||
"userAgent": "test-agent",
|
||||
@@ -99,6 +100,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
WithRoleInfo("role-arn", "assumed-role", "principal").
|
||||
WithIdentityProvider("provider", "external-id", "issuer").
|
||||
WithPolicies(policies).
|
||||
WithSessionPolicy(sessionPolicy).
|
||||
WithRequestContext(requestContext).
|
||||
WithMaxDuration(2 * time.Hour)
|
||||
|
||||
@@ -114,6 +116,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
|
||||
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
|
||||
assert.Equal(t, policies, sessionInfo.Policies)
|
||||
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
|
||||
assert.Equal(t, requestContext, sessionInfo.RequestContext)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
35
weed/iam/sts/session_policy.go
Normal file
35
weed/iam/sts/session_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
// NormalizeSessionPolicy validates and normalizes inline session policy JSON.
|
||||
// It returns an empty string if the input is empty or whitespace.
|
||||
func NormalizeSessionPolicy(policyJSON string) (string, error) {
|
||||
trimmed := strings.TrimSpace(policyJSON)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
const maxSessionPolicySize = 2048
|
||||
if len(trimmed) > maxSessionPolicySize {
|
||||
return "", fmt.Errorf("session policy exceeds maximum size of %d characters", maxSessionPolicySize)
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(trimmed), &policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
normalized, err := json.Marshal(&policyDoc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to normalize session policy: %w", err)
|
||||
}
|
||||
return string(normalized), nil
|
||||
}
|
||||
@@ -25,174 +25,55 @@ func createSessionPolicyTestJWT(t *testing.T, issuer, subject string) string {
|
||||
return tokenString
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy tests the handling of the Policy field
|
||||
// in AssumeRoleWithWebIdentityRequest to ensure users are properly informed that
|
||||
// session policies are not currently supported
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy verifies inline session policies are preserved in tokens.
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
|
||||
t.Run("should_reject_request_with_session_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a request with a session policy
|
||||
sessionPolicy := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::example-bucket/*"
|
||||
}]
|
||||
}`
|
||||
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: nil, // Use default
|
||||
Policy: &sessionPolicy, // ← Session policy provided
|
||||
}
|
||||
|
||||
// Should return an error indicating session policies are not supported
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
// Verify the error
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
assert.Contains(t, err.Error(), "Policy parameter must be omitted")
|
||||
})
|
||||
|
||||
t.Run("should_succeed_without_session_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: nil, // Use default
|
||||
Policy: nil, // ← No session policy
|
||||
}
|
||||
|
||||
// Should succeed without session policy
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
// Verify success
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Credentials)
|
||||
assert.NotEmpty(t, response.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, response.Credentials.SecretAccessKey)
|
||||
assert.NotEmpty(t, response.Credentials.SessionToken)
|
||||
})
|
||||
|
||||
t.Run("should_succeed_with_empty_policy_pointer", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
Policy: nil, // ← Explicitly nil
|
||||
}
|
||||
|
||||
// Should succeed with nil policy pointer
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Credentials)
|
||||
})
|
||||
|
||||
t.Run("should_reject_empty_string_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
emptyPolicy := "" // Empty string, but still a non-nil pointer
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &emptyPolicy, // ← Non-nil pointer to empty string
|
||||
}
|
||||
|
||||
// Should still reject because pointer is not nil
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy_ErrorMessage tests that the error message
|
||||
// is clear and helps users understand what they need to do
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy_ErrorMessage(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
|
||||
ctx := context.Background()
|
||||
complexPolicy := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowS3Access",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket/*",
|
||||
"arn:aws:s3:::my-bucket"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": ["documents/", "images/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::example-bucket/*"}]}`
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session-with-complex-policy",
|
||||
Policy: &complexPolicy,
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Verify error details
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
errorMsg := err.Error()
|
||||
normalized, err := NormalizeSessionPolicy(sessionPolicy)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
|
||||
|
||||
// The error should be clear and actionable
|
||||
assert.Contains(t, errorMsg, "session policies are not currently supported",
|
||||
"Error should explain that session policies aren't supported")
|
||||
assert.Contains(t, errorMsg, "Policy parameter must be omitted",
|
||||
"Error should specify what action the user needs to take")
|
||||
t.Run("should_succeed_without_session_policy", func(t *testing.T) {
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
}
|
||||
|
||||
// Should NOT contain internal implementation details
|
||||
assert.NotContains(t, errorMsg, "nil pointer",
|
||||
"Error should not expose internal implementation details")
|
||||
assert.NotContains(t, errorMsg, "struct field",
|
||||
"Error should not expose internal struct details")
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sessionInfo.SessionPolicy)
|
||||
})
|
||||
}
|
||||
|
||||
// Test edge case scenarios for the Policy field handling
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("malformed_json_policy_still_rejected", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("malformed_json_policy_rejected", func(t *testing.T) {
|
||||
malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
@@ -202,17 +83,30 @@ func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
Policy: &malformedPolicy,
|
||||
}
|
||||
|
||||
// Should reject before even parsing the policy JSON
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
assert.Contains(t, err.Error(), "invalid session policy JSON")
|
||||
})
|
||||
|
||||
t.Run("policy_with_whitespace_still_rejected", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
whitespacePolicy := " \t\n " // Only whitespace
|
||||
t.Run("invalid_policy_document_rejected", func(t *testing.T) {
|
||||
invalidPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow"}]}`
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &invalidPolicy,
|
||||
}
|
||||
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "invalid session policy document")
|
||||
})
|
||||
|
||||
t.Run("whitespace_policy_ignored", func(t *testing.T) {
|
||||
whitespacePolicy := " \t\n "
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
@@ -221,58 +115,54 @@ func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
Policy: &whitespacePolicy,
|
||||
}
|
||||
|
||||
// Should reject any non-nil policy, even whitespace
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sessionInfo.SessionPolicy)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation verifies that the struct
|
||||
// field is properly documented to help developers understand the limitation
|
||||
// TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation verifies that the struct field exists and is optional.
|
||||
func TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation(t *testing.T) {
|
||||
// This test documents the current behavior and ensures the struct field
|
||||
// exists with proper typing
|
||||
request := &AssumeRoleWithWebIdentityRequest{}
|
||||
|
||||
// Verify the Policy field exists and has the correct type
|
||||
assert.IsType(t, (*string)(nil), request.Policy,
|
||||
"Policy field should be *string type for optional JSON policy")
|
||||
|
||||
// Verify initial value is nil (no policy by default)
|
||||
assert.Nil(t, request.Policy,
|
||||
"Policy field should default to nil (no session policy)")
|
||||
|
||||
// Test that we can set it to a string pointer (even though it will be rejected)
|
||||
policyValue := `{"Version": "2012-10-17"}`
|
||||
request.Policy = &policyValue
|
||||
assert.NotNil(t, request.Policy, "Should be able to assign policy value")
|
||||
assert.Equal(t, policyValue, *request.Policy, "Policy value should be preserved")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentials_NoSessionPolicySupport verifies that
|
||||
// AssumeRoleWithCredentialsRequest doesn't have a Policy field, which is correct
|
||||
// since credential-based role assumption typically doesn't support session policies
|
||||
func TestAssumeRoleWithCredentials_NoSessionPolicySupport(t *testing.T) {
|
||||
// Verify that AssumeRoleWithCredentialsRequest doesn't have a Policy field
|
||||
// This is the expected behavior since session policies are typically only
|
||||
// supported with web identity (OIDC/SAML) flows in AWS STS
|
||||
// TestAssumeRoleWithCredentials_SessionPolicy verifies session policy support for credentials-based flow.
|
||||
func TestAssumeRoleWithCredentials_SessionPolicy(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"filer:CreateEntry","Resource":"arn:aws:filer::path/user-docs/*"}]}`
|
||||
request := &AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "test-session",
|
||||
ProviderName: "ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
// The struct should compile and work without a Policy field
|
||||
assert.NotNil(t, request)
|
||||
assert.Equal(t, "arn:aws:iam::role/TestRole", request.RoleArn)
|
||||
assert.Equal(t, "testuser", request.Username)
|
||||
response, err := service.AssumeRoleWithCredentials(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// This documents that credential-based assume role does NOT support session policies
|
||||
// which matches AWS STS behavior where session policies are primarily for
|
||||
// web identity (OIDC/SAML) and federation scenarios
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalized, err := NormalizeSessionPolicy(sessionPolicy)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ type AssumeRoleWithCredentialsRequest struct {
|
||||
|
||||
// DurationSeconds is the duration of the role session (optional)
|
||||
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
||||
|
||||
// Policy is an optional session policy (optional)
|
||||
Policy *string `json:"Policy,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleResponse represents the response from assume role operations
|
||||
@@ -237,6 +240,9 @@ type SessionInfo struct {
|
||||
// Policies are the policies associated with this session
|
||||
Policies []string `json:"policies"`
|
||||
|
||||
// SessionPolicy is the inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"sessionPolicy,omitempty"`
|
||||
|
||||
// RequestContext contains additional request context for policy evaluation
|
||||
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
|
||||
|
||||
@@ -418,9 +424,13 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
// Check for unsupported session policy
|
||||
sessionPolicy := ""
|
||||
if request.Policy != nil {
|
||||
return nil, fmt.Errorf("session policies are not currently supported - Policy parameter must be omitted")
|
||||
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session policy: %w", err)
|
||||
}
|
||||
sessionPolicy = normalized
|
||||
}
|
||||
|
||||
// 1. Validate the web identity token with appropriate provider
|
||||
@@ -485,6 +495,9 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
|
||||
WithMaxDuration(sessionDuration).
|
||||
WithRequestContext(requestContext)
|
||||
if sessionPolicy != "" {
|
||||
sessionClaims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
|
||||
// Generate self-contained JWT token with all session information
|
||||
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
||||
@@ -517,6 +530,15 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
sessionPolicy := ""
|
||||
if request.Policy != nil {
|
||||
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session policy: %w", err)
|
||||
}
|
||||
sessionPolicy = normalized
|
||||
}
|
||||
|
||||
// 1. Get the specified provider
|
||||
provider, exists := s.providers[request.ProviderName]
|
||||
if !exists {
|
||||
@@ -565,6 +587,9 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
|
||||
WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
|
||||
WithMaxDuration(sessionDuration)
|
||||
if sessionPolicy != "" {
|
||||
sessionClaims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
|
||||
// Generate self-contained JWT token with all session information
|
||||
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
||||
|
||||
@@ -16,6 +16,9 @@ const (
|
||||
|
||||
// iamRoleMarker is the marker that identifies IAM role ARNs
|
||||
iamRoleMarker = "role/"
|
||||
|
||||
// iamUserMarker is the marker that identifies IAM user ARNs
|
||||
iamUserMarker = "user/"
|
||||
)
|
||||
|
||||
// ARNInfo contains structured information about a parsed AWS ARN.
|
||||
@@ -88,6 +91,36 @@ func ExtractRoleNameFromPrincipal(principal string) string {
|
||||
return ExtractRoleNameFromArn(principal)
|
||||
}
|
||||
|
||||
// ExtractUserNameFromPrincipal extracts the user name from an AWS IAM principal ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM user ARN formats:
|
||||
// - arn:aws:iam::user/UserName (legacy format without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:user/UserName (standard AWS format with account ID)
|
||||
//
|
||||
// Returns an empty string if the principal does not represent an IAM user.
|
||||
func ExtractUserNameFromPrincipal(principal string) string {
|
||||
if !strings.HasPrefix(principal, iamPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
remainder := principal[len(iamPrefix):]
|
||||
resourcePart := remainder
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(resourcePart, iamUserMarker) {
|
||||
return ""
|
||||
}
|
||||
|
||||
userName := resourcePart[len(iamUserMarker):]
|
||||
if userName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return userName
|
||||
}
|
||||
|
||||
// ExtractRoleNameFromArn extracts the role name from an AWS IAM role ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM role ARN formats:
|
||||
|
||||
Reference in New Issue
Block a user