diff --git a/test/s3tables/sts_integration/sts_integration_test.go b/test/s3tables/sts_integration/sts_integration_test.go index 33b78e06f..a6e4c7bbd 100644 --- a/test/s3tables/sts_integration/sts_integration_test.go +++ b/test/s3tables/sts_integration/sts_integration_test.go @@ -33,6 +33,8 @@ type TestEnvironment struct { secretKey string } +const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests + func TestSTSIntegration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") @@ -110,17 +112,70 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment { volumePort: volumePort, volumeGrpcPort: volumeGrpcPort, dockerAvailable: testutil.HasDocker(), - accessKey: "admin", // Matching default in testutil.WriteIAMConfig - secretKey: "admin", + accessKey: "admin", + secretKey: "adminadmin", } } func (env *TestEnvironment) StartSeaweedFS(t *testing.T) { t.Helper() - // Create IAM config file - iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey) - if err != nil { + iamConfigPath := filepath.Join(env.dataDir, "iam.json") + // Note: signingKey must be base64 encoded for []byte JSON unmarshaling + iamConfig := fmt.Sprintf(`{ + "identities": [ + { + "name": "admin", + "credentials": [ + { "accessKey": "%s", "secretKey": "%s" } + ], + "actions": ["Admin", "Read", "Write", "List", "Tagging"] + } + ], + "sts": { + "tokenDuration": "1h", + "maxSessionLength": "12h", + "issuer": "seaweedfs-sts", + "signingKey": "%s" + }, + "policy": { + "defaultEffect": "Deny", + "storeType": "memory" + }, + "policies": [ + { + "name": "S3FullAccessPolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": ["*"] + } + ] + } + } + ], + "roles": [ + { + "roleName": "TestRole", + "roleArn": "arn:aws:iam::role/TestRole", + "attachedPolicies": ["S3FullAccessPolicy"], + "trustPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": ["sts:AssumeRole"] + } + ] + } + } + ] +}`, env.accessKey, env.secretKey, testSTSIntegrationSigningKey) + if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil { t.Fatalf("Failed to create IAM config: %v", err) } @@ -143,6 +198,8 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) { "-s3.port", fmt.Sprintf("%d", env.s3Port), "-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort), "-s3.config", iamConfigPath, + "-s3.iam.config", iamConfigPath, + "-s3.iam.readOnly", "false", "-ip", env.bindIP, "-ip.bind", "0.0.0.0", "-dir", env.dataDir, @@ -190,22 +247,62 @@ func runPythonSTSClient(t *testing.T, env *TestEnvironment) { import boto3 import botocore.config from botocore.exceptions import ClientError -import os +import json import sys +import time +import urllib.error +import urllib.request -print("Starting STS test...") +print("Starting STS inline session policy test...") -endpoint_url = "http://host.docker.internal:%d" +primary_endpoint = "http://host.docker.internal:%d" +fallback_endpoint = "http://%s:%d" access_key = "%s" secret_key = "%s" region = "us-east-1" -print(f"Connecting to {endpoint_url} with key {access_key}") - try: + def wait_for_endpoint(url, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2): + return True + except urllib.error.HTTPError: + return True + except Exception: + time.sleep(1) + return False + + def select_endpoint(urls): + for url in urls: + if wait_for_endpoint(url): + return url + raise Exception("No reachable S3 endpoint from container") + + endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint]) + print(f"Using endpoint {endpoint_url}") + config = botocore.config.Config( - retries={'max_attempts': 0} + retries={'max_attempts': 0}, + s3={'addressing_style': 'path'} + ) + admin_s3 = boto3.client( + 's3', + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + config=config ) + + bucket = f"sts-inline-policy-{int(time.time() * 1000)}" + key = "allowed.txt" + + print(f"Creating bucket {bucket} with admin credentials") + admin_s3.create_bucket(Bucket=bucket) + admin_s3.put_object(Bucket=bucket, Key=key, Body=b"ok") + sts = boto3.client( 'sts', endpoint_url=endpoint_url, @@ -215,45 +312,77 @@ try: config=config ) - role_arn = "arn:aws:iam::000000000000:role/test-role" + role_arn = "arn:aws:iam::role/TestRole" session_name = "test-session" - - print(f"Calling AssumeRole on {role_arn}") - - # This call typically sends parameters in POST body by default in boto3 + session_policy = json.dumps({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": [f"arn:aws:s3:::{bucket}"] + }, + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket}/*"] + } + ] + }) + + print(f"Calling AssumeRole on {role_arn} with inline session policy") response = sts.assume_role( RoleArn=role_arn, - RoleSessionName=session_name + RoleSessionName=session_name, + Policy=session_policy ) - print("Success! Got credentials:") - print(response['Credentials']) - -except ClientError as e: - # Print available keys for debugging if needed - # print(e.response.keys()) - - response_meta = e.response.get('ResponseMetadata', {}) - http_code = response_meta.get('HTTPStatusCode') - - error_data = e.response.get('Error', {}) - error_code = error_data.get('Code', 'Unknown') - - print(f"Got error: {http_code} {error_code}") - - # We expect 503 ServiceUnavailable because stsHandlers is nil in weed mini - # This confirms the request was routed to STS handler logic (UnifiedPostHandler) - # instead of IAM handler (which would return 403 AccessDenied or 501 NotImplemented) - if http_code == 503: - print("SUCCESS: Got expected 503 Service Unavailable (STS not configured)") - sys.exit(0) - - print(f"FAILED: Unexpected error {e}") - sys.exit(1) + creds = response['Credentials'] + vended_s3 = boto3.client( + 's3', + endpoint_url=endpoint_url, + aws_access_key_id=creds['AccessKeyId'], + aws_secret_access_key=creds['SecretAccessKey'], + aws_session_token=creds['SessionToken'], + region_name=region, + config=config + ) + + print("Listing objects (allowed)") + list_resp = vended_s3.list_objects_v2(Bucket=bucket) + keys = [obj.get('Key') for obj in list_resp.get('Contents', [])] + if key not in keys: + print(f"FAILED: Expected to see {key} in list_objects_v2 results") + sys.exit(1) + + print("Getting object (allowed)") + body = vended_s3.get_object(Bucket=bucket, Key=key)['Body'].read() + if body != b"ok": + print("FAILED: Unexpected object content") + sys.exit(1) + + print("Putting object (expected to be denied)") + try: + vended_s3.put_object(Bucket=bucket, Key="denied.txt", Body=b"no") + print("FAILED: PutObject unexpectedly succeeded") + sys.exit(1) + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code', '') + if error_code != 'AccessDenied': + print(f"FAILED: Expected AccessDenied, got {error_code}") + sys.exit(1) + print("PutObject correctly denied by inline session policy") + + print("SUCCESS: Inline session policy downscoping verified") + sys.exit(0) except Exception as e: print(f"FAILED: {e}") + if hasattr(e, 'response'): + print(f"Response: {e.response}") + import traceback + traceback.print_exc() sys.exit(1) -`, env.s3Port, env.accessKey, env.secretKey) +`, env.s3Port, env.bindIP, env.s3Port, env.accessKey, env.secretKey) scriptPath := filepath.Join(env.dataDir, "sts_test.py") if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil { diff --git a/weed/iam/integration/iam_integration_test.go b/weed/iam/integration/iam_integration_test.go index dcdda7bed..bb696597e 100644 --- a/weed/iam/integration/iam_integration_test.go +++ b/weed/iam/integration/iam_integration_test.go @@ -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) diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index ccbb5ef00..fc1b043df 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -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) + } + 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) } - // 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) - } - result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies) - if err != nil { - return false, fmt.Errorf("policy evaluation failed: %w", err) + policies = roleDef.AttachedPolicies } - 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 diff --git a/weed/iam/policy/policy_engine.go b/weed/iam/policy/policy_engine.go index 7e5c494cf..eef81e36b 100644 --- a/weed/iam/policy/policy_engine.go +++ b/weed/iam/policy/policy_engine.go @@ -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) diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index 203524d4e..ce923504f 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -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 diff --git a/weed/iam/sts/session_claims_test.go b/weed/iam/sts/session_claims_test.go index d7a1769bb..007f2404b 100644 --- a/weed/iam/sts/session_claims_test.go +++ b/weed/iam/sts/session_claims_test.go @@ -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) } diff --git a/weed/iam/sts/session_policy.go b/weed/iam/sts/session_policy.go new file mode 100644 index 000000000..1670f055e --- /dev/null +++ b/weed/iam/sts/session_policy.go @@ -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 +} diff --git a/weed/iam/sts/session_policy_test.go b/weed/iam/sts/session_policy_test.go index 83267fd83..992fde929 100644 --- a/weed/iam/sts/session_policy_test.go +++ b/weed/iam/sts/session_policy_test.go @@ -25,194 +25,88 @@ 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) + ctx := context.Background() - 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/*" - }] - }` + sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::example-bucket/*"}]}` + testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user") - testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user") + request := &AssumeRoleWithWebIdentityRequest{ + RoleArn: "arn:aws:iam::role/TestRole", + WebIdentityToken: testToken, + RoleSessionName: "test-session", + Policy: &sessionPolicy, + } - request := &AssumeRoleWithWebIdentityRequest{ - RoleArn: "arn:aws:iam::role/TestRole", - WebIdentityToken: testToken, - RoleSessionName: "test-session", - DurationSeconds: nil, // Use default - Policy: &sessionPolicy, // ← Session policy provided - } + response, err := service.AssumeRoleWithWebIdentity(ctx, request) + require.NoError(t, err) + require.NotNil(t, response) - // Should return an error indicating session policies are not supported - response, err := service.AssumeRoleWithWebIdentity(ctx, request) + sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken) + require.NoError(t, err) - // 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") - }) + normalized, err := NormalizeSessionPolicy(sessionPolicy) + require.NoError(t, err) + assert.Equal(t, normalized, sessionInfo.SessionPolicy) 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, + WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"), 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) + sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken) require.NoError(t, err) - require.NotNil(t, response) - assert.NotNil(t, response.Credentials) + assert.Empty(t, sessionInfo.SessionPolicy) }) +} - t.Run("should_reject_empty_string_policy", func(t *testing.T) { - ctx := context.Background() +// Test edge case scenarios for the Policy field handling +func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) { + service := setupTestSTSService(t) + ctx := context.Background() - emptyPolicy := "" // Empty string, but still a non-nil pointer + t.Run("malformed_json_policy_rejected", func(t *testing.T) { + malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON 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 + Policy: &malformedPolicy, } - // 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") + assert.Contains(t, err.Error(), "invalid session policy JSON") }) -} - -// 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/"] - } - } - } - ] - }` - - 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, - } - - response, err := service.AssumeRoleWithWebIdentity(ctx, request) - - // Verify error details - require.Error(t, err) - assert.Nil(t, response) - errorMsg := err.Error() - - // 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") - - // 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") -} - -// Test edge case scenarios for the Policy field handling -func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) { - service := setupTestSTSService(t) - - t.Run("malformed_json_policy_still_rejected", func(t *testing.T) { - ctx := context.Background() - malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON + 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: &malformedPolicy, + Policy: &invalidPolicy, } - // 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 document") }) - t.Run("policy_with_whitespace_still_rejected", func(t *testing.T) { - ctx := context.Background() - whitespacePolicy := " \t\n " // Only whitespace + 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) + + sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken) + require.NoError(t, err) - // 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 + normalized, err := NormalizeSessionPolicy(sessionPolicy) + require.NoError(t, err) + assert.Equal(t, normalized, sessionInfo.SessionPolicy) } diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index ffbd69e27..f3df00fd2 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -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) diff --git a/weed/iam/utils/arn_utils.go b/weed/iam/utils/arn_utils.go index 5b29b7766..a1361e30e 100644 --- a/weed/iam/utils/arn_utils.go +++ b/weed/iam/utils/arn_utils.go @@ -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: diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 4a3fa4554..2b1f450b8 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -189,6 +189,9 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl if err != nil { glog.Errorf("Failed to load IAM configuration: %v", err) } else { + if iam.credentialManager != nil { + iamManager.SetUserStore(iam.credentialManager) + } glog.V(1).Infof("IAM Manager loaded, creating integration") // Create S3 IAM integration with the loaded IAM manager // filerAddress not actually used, just for backward compatibility diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go index 0a5c565c7..fc5e37de6 100644 --- a/weed/s3api/s3api_sts.go +++ b/weed/s3api/s3api_sts.go @@ -165,12 +165,25 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r * return } + sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy")) + if err != nil { + h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument, + fmt.Errorf("invalid Policy document: %w", err)) + return + } + + var sessionPolicyPtr *string + if sessionPolicyJSON != "" { + sessionPolicyPtr = &sessionPolicyJSON + } + // Build request for STS service request := &sts.AssumeRoleWithWebIdentityRequest{ RoleArn: roleArn, WebIdentityToken: webIdentityToken, RoleSessionName: roleSessionName, DurationSeconds: durationSeconds, + Policy: sessionPolicyPtr, } // Call STS service @@ -216,6 +229,8 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r * // handleAssumeRole handles the AssumeRole API action // This requires AWS Signature V4 authentication +// Inline session policies (Policy parameter) are supported for AssumeRole, +// AssumeRoleWithWebIdentity, and AssumeRoleWithLDAPIdentity. func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { // Extract parameters from form roleArn := r.FormValue("RoleArn") @@ -290,8 +305,16 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { return } + // Parse optional inline session policy for downscoping + sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy")) + if err != nil { + h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument, + fmt.Errorf("invalid Policy document: %w", err)) + return + } + // Generate common STS components - stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, nil) + stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, nil) if err != nil { h.writeSTSErrorResponse(w, r, STSErrInternalError, err) return @@ -420,12 +443,19 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r return } + sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy")) + if err != nil { + h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument, + fmt.Errorf("invalid Policy document: %w", err)) + return + } + // Generate common STS components with LDAP-specific claims modifyClaims := func(claims *sts.STSSessionClaims) { claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider) } - stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, modifyClaims) + stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims) if err != nil { h.writeSTSErrorResponse(w, r, STSErrInternalError, err) return @@ -445,7 +475,7 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r // prepareSTSCredentials extracts common shared logic for credential generation func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string, - durationSeconds *int64, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) { + durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) { // Calculate duration duration := time.Hour // Default 1 hour @@ -479,6 +509,10 @@ func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string, WithSessionName(roleSessionName). WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn) + if sessionPolicy != "" { + claims.WithSessionPolicy(sessionPolicy) + } + // Apply custom claims if provided (e.g., LDAP identity) if modifyClaims != nil { modifyClaims(claims) @@ -582,13 +616,14 @@ type LDAPIdentityResult struct { type STSErrorCode string const ( - STSErrAccessDenied STSErrorCode = "AccessDenied" - STSErrExpiredToken STSErrorCode = "ExpiredTokenException" - STSErrInvalidAction STSErrorCode = "InvalidAction" - STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue" - STSErrMissingParameter STSErrorCode = "MissingParameter" - STSErrSTSNotReady STSErrorCode = "ServiceUnavailable" - STSErrInternalError STSErrorCode = "InternalError" + STSErrAccessDenied STSErrorCode = "AccessDenied" + STSErrExpiredToken STSErrorCode = "ExpiredTokenException" + STSErrInvalidAction STSErrorCode = "InvalidAction" + STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue" + STSErrMalformedPolicyDocument STSErrorCode = "MalformedPolicyDocument" + STSErrMissingParameter STSErrorCode = "MissingParameter" + STSErrSTSNotReady STSErrorCode = "ServiceUnavailable" + STSErrInternalError STSErrorCode = "InternalError" ) // stsErrorResponses maps error codes to HTTP status and messages @@ -596,13 +631,14 @@ var stsErrorResponses = map[STSErrorCode]struct { HTTPStatusCode int Message string }{ - STSErrAccessDenied: {http.StatusForbidden, "Access Denied"}, - STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"}, - STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"}, - STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"}, - STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"}, - STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"}, - STSErrInternalError: {http.StatusInternalServerError, "Internal error"}, + STSErrAccessDenied: {http.StatusForbidden, "Access Denied"}, + STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"}, + STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"}, + STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"}, + STSErrMalformedPolicyDocument: {http.StatusBadRequest, "Malformed policy document"}, + STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"}, + STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"}, + STSErrInternalError: {http.StatusInternalServerError, "Internal error"}, } // STSErrorResponse is the XML error response format