From ae9a943ef630f920e0f11c7a809334cdc2170c58 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 29 Dec 2025 20:17:23 -0800 Subject: [PATCH] IAM: Add Service Account Support (#7744) (#7901) * iam: add ServiceAccount protobuf schema Add ServiceAccount message type to iam.proto with support for: - Unique ID and parent user linkage - Optional expiration timestamp - Separate credentials (access key/secret) - Action restrictions (subset of parent) - Enable/disable status This is the first step toward implementing issue #7744 (IAM Service Account Support). * iam: add service account response types Add IAM API response types for service account operations: - ServiceAccountInfo struct for marshaling account details - CreateServiceAccountResponse - DeleteServiceAccountResponse - ListServiceAccountsResponse - GetServiceAccountResponse - UpdateServiceAccountResponse Also add type aliases in iamapi package for backwards compatibility. Part of issue #7744 (IAM Service Account Support). * iam: implement service account API handlers Add CRUD operations for service accounts: - CreateServiceAccount: Creates service account with ABIA key prefix - DeleteServiceAccount: Removes service account and parent linkage - ListServiceAccounts: Lists all or filtered by parent user - GetServiceAccount: Retrieves service account details - UpdateServiceAccount: Modifies status, description, expiration Service accounts inherit parent user's actions by default and support optional expiration timestamps. Part of issue #7744 (IAM Service Account Support). * sts: add AssumeRoleWithWebIdentity HTTP endpoint Add STS API HTTP endpoint for AWS SDK compatibility: - Create s3api_sts.go with HTTP handlers matching AWS STS spec - Support AssumeRoleWithWebIdentity action with JWT token - Return XML response with temporary credentials (AccessKeyId, SecretAccessKey, SessionToken) matching AWS format - Register STS route at POST /?Action=AssumeRoleWithWebIdentity This enables AWS SDKs (boto3, AWS CLI, etc.) to obtain temporary S3 credentials using OIDC/JWT tokens. Part of issue #7744 (IAM Service Account Support). * test: add service account and STS integration tests Add integration tests for new IAM features: s3_service_account_test.go: - TestServiceAccountLifecycle: Create, Get, List, Update, Delete - TestServiceAccountValidation: Error handling for missing params s3_sts_test.go: - TestAssumeRoleWithWebIdentityValidation: Parameter validation - TestAssumeRoleWithWebIdentityWithMockJWT: JWT token handling Tests skip gracefully when SeaweedFS is not running or when IAM features are not configured. Part of issue #7744 (IAM Service Account Support). * iam: address code review comments - Add constants for service account ID and key lengths - Use strconv.ParseInt instead of fmt.Sscanf for better error handling - Allow clearing descriptions by checking key existence in url.Values - Replace magic numbers (12, 20, 40) with named constants Addresses review comments from gemini-code-assist[bot] * test: add proper error handling in service account tests Use require.NoError(t, err) for io.ReadAll and xml.Unmarshal to prevent silent failures and ensure test reliability. Addresses review comment from gemini-code-assist[bot] * test: add proper error handling in STS tests Use require.NoError(t, err) for io.ReadAll and xml.Unmarshal to prevent silent failures and ensure test reliability. Repeated this fix throughout the file. Addresses review comment from gemini-code-assist[bot] in PR #7901. * iam: address additional code review comments - Specific error code mapping for STS service errors - Distinguish between Sender and Receiver error types in STS responses - Add nil checks for credentials in List/GetServiceAccount - Validate expiration date is in the future - Improve integration test error messages (include response body) - Add credential verification step in service account tests Addresses remaining review comments from gemini-code-assist[bot] across multiple files. * iam: fix shared slice reference in service account creation Copy parent's actions to create an independent slice for the service account instead of sharing the underlying array. This prevents unexpected mutations when the parent's actions are modified later. Addresses review comment from coderabbitai[bot] in PR #7901. * iam: remove duplicate unused constant Removed redundant iamServiceAccountKeyPrefix as ServiceAccountKeyPrefix is already defined and used. Addresses remaining cleanup task. * sts: document limitation of string-based error mapping Added TODO comment explaining that the current string-based error mapping approach is fragile and should be replaced with typed errors from the STS service in a future refactoring. This addresses the architectural concern raised in code review while deferring the actual implementation to a separate PR to avoid scope creep in the current service account feature addition. * iam: fix remaining review issues - Add future-date validation for expiration in UpdateServiceAccount - Reorder tests so credential verification happens before deletion - Fix compilation error by using correct JWT generation methods Addresses final review comments from coderabbitai[bot]. * iam: fix service account access key length The access key IDs were incorrectly generated with 24 characters instead of the AWS-standard 20 characters. This was caused by generating 20 random characters and then prepending the 4-character ABIA prefix. Fixed by subtracting the prefix length from AccessKeyLength, so the final key is: ABIA (4 chars) + random (16 chars) = 20 chars total. This ensures compatibility with S3 clients that validate key length. * test: add comprehensive service account security tests Added comprehensive integration tests for service account functionality: - TestServiceAccountS3Access: Verify SA credentials work for S3 operations - TestServiceAccountExpiration: Test expiration date validation and enforcement - TestServiceAccountInheritedPermissions: Verify parent-child relationship - TestServiceAccountAccessKeyFormat: Validate AWS-compatible key format (ABIA prefix, 20 char length) These tests ensure SeaweedFS service accounts are compatible with AWS conventions and provide robust security coverage. * iam: remove unused UserAccessKeyPrefix constant Code cleanup to remove unused constants. * iam: remove unused iamCommonResponse type alias Code cleanup to remove unused type aliases. * iam: restore and use UserAccessKeyPrefix constant Restored UserAccessKeyPrefix constant and updated s3api tests to use it instead of hardcoded strings for better maintainability and consistency. * test: improve error handling in service account security tests Added explicit error checking for io.ReadAll and xml.Unmarshal in TestServiceAccountExpiration to ensure failures are reported correctly and cleanup is performed only when appropriate. Also added logging for failed responses. * test: use t.Cleanup for reliable resource cleanup Replaced defer with t.Cleanup to ensure service account cleanup runs even when require.NoError fails. Also switched from manual error checking to require.NoError for more idiomatic testify usage. * iam: add CreatedBy field and optimize identity lookups - Added createdBy parameter to CreateServiceAccount to track who created each service account - Extract creator identity from request context using GetIdentityNameFromContext - Populate created_by field in ServiceAccount protobuf - Added findIdentityByName helper function to optimize identity lookups - Replaced nested loops with O(n) helper function calls in CreateServiceAccount and DeleteServiceAccount This addresses code review feedback for better auditing and performance. * iam: prevent user deletion when service accounts exist Following AWS IAM behavior, prevent deletion of users that have active service accounts. This ensures explicit cleanup and prevents orphaned service account resources with invalid ParentUser references. Users must delete all associated service accounts before deleting the parent user, providing safer resource management. * sts: enhance TODO with typed error implementation guidance Updated TODO comment with detailed implementation approach for replacing string-based error matching with typed errors using errors.Is(). This provides a clear roadmap for a follow-up PR to improve error handling robustness and maintainability. * iam: add operational limits for service account creation Added AWS IAM-compatible safeguards to prevent resource exhaustion: - Maximum 100 service accounts per user (LimitExceededException) - Maximum 1000 character description length (InvalidInputException) These limits prevent accidental or malicious resource exhaustion while not impacting legitimate use cases. * iam: add missing operational limit constants Added MaxServiceAccountsPerUser and MaxDescriptionLength constants that were referenced in the previous commit but not defined. * iam: enforce service account expiration during authentication CRITICAL SECURITY FIX: Expired service account credentials were not being rejected during authentication, allowing continued access after expiration. Changes: - Added Expiration field to Credential struct - Populate expiration when loading service accounts from configuration - Check expiration in all authentication paths (V2 and V4 signatures) - Return ErrExpiredToken for expired credentials This ensures expired service accounts are properly rejected at authentication time, matching AWS IAM behavior and preventing unauthorized access. * iam: fix error code for expired service account credentials Use ErrAccessDenied instead of non-existent ErrExpiredToken for expired service account credentials. This provides appropriate access denial for expired credentials while maintaining AWS-compatible error responses. * iam: fix remaining ErrExpiredToken references Replace all remaining instances of non-existent ErrExpiredToken with ErrAccessDenied for expired service account credentials. * iam: apply AWS-standard key format to user access keys Updated CreateAccessKey to generate AWS-standard 20-character access keys with AKIA prefix for regular users, matching the format used for service accounts. This ensures consistency across all access key types and full AWS compatibility. - Access keys: AKIA + 16 random chars = 20 total (was 21 chars, no prefix) - Secret keys: 40 random chars (was 42, now matches AWS standard) - Uses AccessKeyLength and UserAccessKeyPrefix constants * sts: replace fragile string-based error matching with typed errors Implemented robust error handling using typed errors and errors.Is() instead of fragile strings.Contains() matching. This decouples the HTTP layer from service implementation details and prevents errors from being miscategorized if error messages change. Changes: - Added typed error variables to weed/iam/sts/constants.go: * ErrTypedTokenExpired * ErrTypedInvalidToken * ErrTypedInvalidIssuer * ErrTypedInvalidAudience * ErrTypedMissingClaims - Updated STS service to wrap provider authentication errors with typed errors - Replaced strings.Contains() with errors.Is() in HTTP layer for error checking - Removed TODO comment as the improvement is now implemented This makes error handling more maintainable and reliable. * sts: eliminate all string-based error matching with provider-level typed errors Completed the typed error implementation by adding provider-level typed errors and updating provider implementations to return them. This eliminates ALL fragile string matching throughout the entire error handling stack. Changes: - Added typed error definitions to weed/iam/providers/errors.go: * ErrProviderTokenExpired * ErrProviderInvalidToken * ErrProviderInvalidIssuer * ErrProviderInvalidAudience * ErrProviderMissingClaims - Updated OIDC provider to wrap JWT validation errors with typed provider errors - Replaced strings.Contains() with errors.Is() in STS service for error mapping - Complete error chain: Provider -> STS -> HTTP layer, all using errors.Is() This provides: - Reliable error classification independent of error message content - Type-safe error checking throughout the stack - No order-dependent string matching - Maintainable error handling that won't break with message changes * oidc: use jwt.ErrTokenExpired instead of string matching Replaced the last remaining string-based error check with the JWT library's exported typed error. This makes the error detection independent of error message content and more robust against library updates. Changed from: strings.Contains(errMsg, "expired") To: errors.Is(err, jwt.ErrTokenExpired) This completes the elimination of ALL string-based error matching throughout the entire authentication stack. * iam: add description length validation to UpdateServiceAccount Fixed inconsistency where UpdateServiceAccount didn't validate description length against MaxDescriptionLength, allowing operational limits to be bypassed during updates. Now validates that updated descriptions don't exceed 1000 characters, matching the validation in CreateServiceAccount. * iam: refactor expiration check into helper method Extracted duplicated credential expiration check logic into a helper method to reduce code duplication and improve maintainability. Added Credential.isCredentialExpired() method and replaced 5 instances of inline expiration checks across auth_signature_v2.go and auth_signature_v4.go. * iam: address critical Copilot security and consistency feedback Fixed three critical issues identified by Copilot code review: 1. SECURITY: Prevent loading disabled service account credentials - Added check to skip disabled service accounts during credential loading - Disabled accounts can no longer authenticate 2. Add DurationSeconds validation for STS AssumeRoleWithWebIdentity - Enforce AWS-compatible range: 900-43200 seconds (15 min - 12 hours) - Returns proper error for out-of-range values 3. Fix expiration update consistency in UpdateServiceAccount - Added key existence check like Description field - Allows explicit clearing of expiration by setting to empty string - Distinguishes between "not updating" and "clearing expiration" * sts: remove unused durationSecondsStr variable Fixed build error from unused variable after refactoring duration parsing. * iam: address remaining Copilot feedback and remove dead code Completed remaining Copilot code review items: 1. Remove unused getPermission() method (dead code) - Method was defined but never called anywhere 2. Improve slice modification safety in DeleteServiceAccount - Replaced append-with-slice-operations with filter pattern - Avoids potential issues from mutating slice during iteration 3. Fix route registration order - Moved STS route registration BEFORE IAM route - Prevents IAM route from intercepting STS requests - More specific route (with query parameter) now registered first * iam: improve expiration validation and test cleanup robustness Addressed additional Copilot feedback: 1. Make expiration validation more explicit - Added explicit check for negative values - Added comment clarifying that 0 is allowed to clear expiration - Improves code readability and intent 2. Fix test cleanup order in s3_service_account_test.go - Track created service accounts in a slice - Delete all service accounts before deleting parent user - Prevents DeleteConflictException during cleanup - More robust cleanup even if test fails mid-execution Note: s3_service_account_security_test.go already had correct cleanup order due to LIFO defer execution. * test: remove redundant variable assignments Removed duplicate assignments of createdSAId, createdAccessKeyId, and createdSecretAccessKey on lines 148-150 that were already assigned on lines 132-134. --- .../iam/s3_service_account_security_test.go | 431 ++++++++++++++++++ test/s3/iam/s3_service_account_test.go | 363 +++++++++++++++ test/s3/iam/s3_sts_test.go | 260 +++++++++++ weed/iam/oidc/oidc_provider.go | 15 +- weed/iam/providers/errors.go | 22 + weed/iam/responses.go | 52 +++ weed/iam/sts/constants.go | 23 + weed/iam/sts/sts_service.go | 15 + weed/iamapi/iamapi_response.go | 34 +- weed/pb/iam.proto | 16 + weed/pb/iam_pb/iam.pb.go | 202 ++++++-- weed/pb/iam_pb/iam_grpc.pb.go | 2 +- weed/s3api/auth_credentials.go | 62 ++- weed/s3api/auth_signature_v2.go | 21 + weed/s3api/auth_signature_v4.go | 14 + weed/s3api/chunked_reader_v4_test.go | 2 +- weed/s3api/s3api_embedded_iam.go | 356 ++++++++++++++- weed/s3api/s3api_embedded_iam_test.go | 73 ++- weed/s3api/s3api_server.go | 20 +- weed/s3api/s3api_sts.go | 282 ++++++++++++ 20 files changed, 2155 insertions(+), 110 deletions(-) create mode 100644 test/s3/iam/s3_service_account_security_test.go create mode 100644 test/s3/iam/s3_service_account_test.go create mode 100644 test/s3/iam/s3_sts_test.go create mode 100644 weed/iam/providers/errors.go create mode 100644 weed/s3api/s3api_sts.go diff --git a/test/s3/iam/s3_service_account_security_test.go b/test/s3/iam/s3_service_account_security_test.go new file mode 100644 index 000000000..a01ddbcdb --- /dev/null +++ b/test/s3/iam/s3_service_account_security_test.go @@ -0,0 +1,431 @@ +package iam + +// Integration tests for SeaweedFS service accounts. +// These tests ensure comprehensive coverage of service account functionality +// including security, access control, and expiration. + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestServiceAccountS3Access verifies that service accounts can actually +// perform S3 operations using their credentials. +func TestServiceAccountS3Access(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + // Setup: Create a parent user + parentUserName := fmt.Sprintf("s3access-test-%d", time.Now().UnixNano()) + + // Create parent user + resp, err := callIAMAPI(t, "CreateUser", url.Values{ + "UserName": {parentUserName}, + }) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to create parent user") + + defer func() { + // Cleanup: delete parent user + callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}}) + }() + + // Create service account for the parent user + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"S3 Access Test Service Account"}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + require.Equal(t, http.StatusOK, createResp.StatusCode, "Failed to create service account") + + body, err := io.ReadAll(createResp.Body) + require.NoError(t, err) + + var saResp CreateServiceAccountResponse + err = xml.Unmarshal(body, &saResp) + require.NoError(t, err, "Failed to parse CreateServiceAccount response: %s", string(body)) + + accessKeyId := saResp.CreateServiceAccountResult.ServiceAccount.AccessKeyId + secretAccessKey := saResp.CreateServiceAccountResult.ServiceAccount.SecretAccessKey + saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId + + require.NotEmpty(t, accessKeyId, "AccessKeyId should not be empty") + require.NotEmpty(t, secretAccessKey, "SecretAccessKey should not be empty") + + defer func() { + // Cleanup: delete service account + callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}}) + }() + + t.Run("list_buckets_with_sa_credentials", func(t *testing.T) { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(TestIAMEndpoint), + Credentials: credentials.NewStaticCredentials( + accessKeyId, + secretAccessKey, + "", + ), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + }) + require.NoError(t, err) + + s3Client := s3.New(sess) + _, err = s3Client.ListBuckets(&s3.ListBucketsInput{}) + + // We don't necessarily expect success (depends on permissions), + // but we should NOT get InvalidAccessKeyId or SignatureDoesNotMatch + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(), + "Service account credentials should be recognized") + assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(), + "Service account signature should be valid") + } + } + }) + + t.Run("create_bucket_with_sa_credentials", func(t *testing.T) { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(TestIAMEndpoint), + Credentials: credentials.NewStaticCredentials( + accessKeyId, + secretAccessKey, + "", + ), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + }) + require.NoError(t, err) + + s3Client := s3.New(sess) + bucketName := fmt.Sprintf("sa-test-bucket-%d", time.Now().UnixNano()) + + _, err = s3Client.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + + // Check that we get a proper response (success or AccessDenied based on policy) + // but NOT InvalidAccessKeyId + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(), + "Service account credentials should be recognized") + assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(), + "Service account signature should be valid") + } + } else { + // Cleanup if bucket was created + defer s3Client.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: aws.String(bucketName), + }) + } + }) +} + +// TestServiceAccountExpiration verifies that expired service accounts +// are properly rejected. +func TestServiceAccountExpiration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + // Setup: Create a parent user + parentUserName := fmt.Sprintf("expiry-test-%d", time.Now().UnixNano()) + + resp, err := callIAMAPI(t, "CreateUser", url.Values{ + "UserName": {parentUserName}, + }) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + defer func() { + callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}}) + }() + + t.Run("reject_past_expiration", func(t *testing.T) { + // Try to create a service account with expiration in the past + pastExpiration := time.Now().Add(-1 * time.Hour).Unix() + + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"Should fail - past expiration"}, + "Expiration": {strconv.FormatInt(pastExpiration, 10)}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + + // Should fail because expiration is in the past + assert.NotEqual(t, http.StatusOK, createResp.StatusCode, + "Creating service account with past expiration should fail") + }) + + t.Run("accept_future_expiration", func(t *testing.T) { + // Create a service account with expiration in the future + futureExpiration := time.Now().Add(24 * time.Hour).Unix() + + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"Should succeed - future expiration"}, + "Expiration": {strconv.FormatInt(futureExpiration, 10)}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + + assert.Equal(t, http.StatusOK, createResp.StatusCode, + "Creating service account with future expiration should succeed") + + // Parse response to get service account ID for cleanup + if createResp.StatusCode == http.StatusOK { + body, err := io.ReadAll(createResp.Body) + require.NoError(t, err) + + var saResp CreateServiceAccountResponse + require.NoError(t, xml.Unmarshal(body, &saResp)) + + saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId + if saId != "" { + t.Cleanup(func() { + callIAMAPI(t, "DeleteServiceAccount", url.Values{ + "ServiceAccountId": {saId}, + }) + }) + } + } + }) + + t.Run("reject_past_expiration_on_update", func(t *testing.T) { + // Create a valid service account first + futureExpiration := time.Now().Add(24 * time.Hour).Unix() + + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"For update test"}, + "Expiration": {strconv.FormatInt(futureExpiration, 10)}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + require.Equal(t, http.StatusOK, createResp.StatusCode) + + body, err := io.ReadAll(createResp.Body) + require.NoError(t, err) + + var saResp CreateServiceAccountResponse + err = xml.Unmarshal(body, &saResp) + require.NoError(t, err) + + saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId + require.NotEmpty(t, saId) + + defer func() { + callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}}) + }() + + // Try to update with past expiration + pastExpiration := time.Now().Add(-1 * time.Hour).Unix() + + updateResp, err := callIAMAPI(t, "UpdateServiceAccount", url.Values{ + "ServiceAccountId": {saId}, + "Expiration": {strconv.FormatInt(pastExpiration, 10)}, + }) + require.NoError(t, err) + defer updateResp.Body.Close() + + // Should fail because expiration is in the past + assert.NotEqual(t, http.StatusOK, updateResp.StatusCode, + "Updating service account with past expiration should fail") + }) +} + +// TestServiceAccountInheritedPermissions verifies that service accounts +// inherit their parent user's permissions. +// This is a key security test - SAs should not have MORE permissions than parent. +func TestServiceAccountInheritedPermissions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + // Setup: Create a parent user + parentUserName := fmt.Sprintf("inherit-test-%d", time.Now().UnixNano()) + + resp, err := callIAMAPI(t, "CreateUser", url.Values{ + "UserName": {parentUserName}, + }) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + defer func() { + callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}}) + }() + + // Create service account + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"Permissions inheritance test"}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + require.Equal(t, http.StatusOK, createResp.StatusCode) + + body, err := io.ReadAll(createResp.Body) + require.NoError(t, err) + + var saResp CreateServiceAccountResponse + err = xml.Unmarshal(body, &saResp) + require.NoError(t, err) + + saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId + require.NotEmpty(t, saId) + + defer func() { + callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}}) + }() + + t.Run("service_account_linked_to_parent", func(t *testing.T) { + // Verify the service account is correctly linked to the parent + getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{ + "ServiceAccountId": {saId}, + }) + require.NoError(t, err) + defer getResp.Body.Close() + + body, err := io.ReadAll(getResp.Body) + require.NoError(t, err) + + var result GetServiceAccountResponse + err = xml.Unmarshal(body, &result) + require.NoError(t, err, "Failed to parse response: %s", string(body)) + + assert.Equal(t, parentUserName, result.GetServiceAccountResult.ServiceAccount.ParentUser, + "Service account should be linked to correct parent user") + }) + + t.Run("list_shows_correct_parent", func(t *testing.T) { + // List service accounts filtered by parent + listResp, err := callIAMAPI(t, "ListServiceAccounts", url.Values{ + "ParentUser": {parentUserName}, + }) + require.NoError(t, err) + defer listResp.Body.Close() + + body, err := io.ReadAll(listResp.Body) + require.NoError(t, err) + + var listResult ListServiceAccountsResponse + err = xml.Unmarshal(body, &listResult) + require.NoError(t, err) + + // Should find at least one service account for this parent + found := false + for _, sa := range listResult.ListServiceAccountsResult.ServiceAccounts { + if sa.ServiceAccountId == saId { + found = true + assert.Equal(t, parentUserName, sa.ParentUser) + break + } + } + assert.True(t, found, "Service account should appear in list filtered by parent") + }) +} + +// TestServiceAccountAccessKeyFormat verifies that service account access keys +// follow the correct AWS format. +func TestServiceAccountAccessKeyFormat(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + // Setup: Create a parent user + parentUserName := fmt.Sprintf("keyformat-test-%d", time.Now().UnixNano()) + + resp, err := callIAMAPI(t, "CreateUser", url.Values{ + "UserName": {parentUserName}, + }) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + defer func() { + callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}}) + }() + + createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"Key format test"}, + }) + require.NoError(t, err) + defer createResp.Body.Close() + require.Equal(t, http.StatusOK, createResp.StatusCode) + + body, err := io.ReadAll(createResp.Body) + require.NoError(t, err) + + var saResp CreateServiceAccountResponse + err = xml.Unmarshal(body, &saResp) + require.NoError(t, err) + + accessKeyId := saResp.CreateServiceAccountResult.ServiceAccount.AccessKeyId + secretAccessKey := saResp.CreateServiceAccountResult.ServiceAccount.SecretAccessKey + saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId + + defer func() { + callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}}) + }() + + t.Run("access_key_has_correct_prefix", func(t *testing.T) { + // Service account access keys should start with ABIA + assert.True(t, len(accessKeyId) >= 4, + "Access key should be at least 4 characters") + assert.Equal(t, "ABIA", accessKeyId[:4], + "Service account access key should start with ABIA prefix") + }) + + t.Run("access_key_has_correct_length", func(t *testing.T) { + // AWS access keys are 20 characters + assert.Equal(t, 20, len(accessKeyId), + "Access key should be exactly 20 characters (AWS standard)") + }) + + t.Run("secret_key_has_correct_length", func(t *testing.T) { + // AWS secret keys are 40 characters + assert.Equal(t, 40, len(secretAccessKey), + "Secret key should be exactly 40 characters (AWS standard)") + }) +} diff --git a/test/s3/iam/s3_service_account_test.go b/test/s3/iam/s3_service_account_test.go new file mode 100644 index 000000000..a4e5ab68b --- /dev/null +++ b/test/s3/iam/s3_service_account_test.go @@ -0,0 +1,363 @@ +package iam + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Service Account API test constants +const ( + TestIAMEndpoint = "http://localhost:8333" +) + +// ServiceAccountInfo represents the response structure for service account operations +type ServiceAccountInfo struct { + ServiceAccountId string `xml:"ServiceAccountId"` + ParentUser string `xml:"ParentUser"` + Description string `xml:"Description,omitempty"` + AccessKeyId string `xml:"AccessKeyId"` + SecretAccessKey string `xml:"SecretAccessKey,omitempty"` + Status string `xml:"Status"` + Expiration string `xml:"Expiration,omitempty"` + CreateDate string `xml:"CreateDate"` +} + +// CreateServiceAccountResponse represents the response for CreateServiceAccount +type CreateServiceAccountResponse struct { + XMLName xml.Name `xml:"CreateServiceAccountResponse"` + CreateServiceAccountResult struct { + ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"` + } `xml:"CreateServiceAccountResult"` +} + +// ListServiceAccountsResponse represents the response for ListServiceAccounts +type ListServiceAccountsResponse struct { + XMLName xml.Name `xml:"ListServiceAccountsResponse"` + ListServiceAccountsResult struct { + ServiceAccounts []ServiceAccountInfo `xml:"ServiceAccounts>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"ListServiceAccountsResult"` +} + +// GetServiceAccountResponse represents the response for GetServiceAccount +type GetServiceAccountResponse struct { + XMLName xml.Name `xml:"GetServiceAccountResponse"` + GetServiceAccountResult struct { + ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"` + } `xml:"GetServiceAccountResult"` +} + +// TestServiceAccountLifecycle tests the complete lifecycle of service accounts +// This is a high-value test covering Create, Get, List, Update, Delete operations +func TestServiceAccountLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Check if SeaweedFS is running + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + // First, ensure the parent user exists + parentUserName := fmt.Sprintf("testuser-%d", time.Now().UnixNano()) + + t.Run("create_parent_user", func(t *testing.T) { + resp, err := callIAMAPI(t, "CreateUser", url.Values{ + "UserName": {parentUserName}, + }) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "CreateUser should succeed") + }) + + // Store service account IDs for cleanup + var createdServiceAccounts []string + + defer func() { + // Cleanup: delete all service accounts first, then parent user + for _, saId := range createdServiceAccounts { + resp, _ := callIAMAPI(t, "DeleteServiceAccount", url.Values{ + "ServiceAccountId": {saId}, + }) + if resp != nil { + resp.Body.Close() + } + } + // Now delete the parent user + resp, _ := callIAMAPI(t, "DeleteUser", url.Values{ + "UserName": {parentUserName}, + }) + if resp != nil { + resp.Body.Close() + } + }() + + var createdSAId string + var createdAccessKeyId string + var createdSecretAccessKey string + + t.Run("create_service_account", func(t *testing.T) { + resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {parentUserName}, + "Description": {"Test service account for CI/CD"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "CreateServiceAccount should succeed") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var createResp CreateServiceAccountResponse + err = xml.Unmarshal(body, &createResp) + require.NoError(t, err) + + sa := createResp.CreateServiceAccountResult.ServiceAccount + createdSAId = sa.ServiceAccountId + createdAccessKeyId = sa.AccessKeyId + createdSecretAccessKey = sa.SecretAccessKey + + // Add to cleanup list + createdServiceAccounts = append(createdServiceAccounts, createdSAId) + + assert.NotEmpty(t, createdSAId, "ServiceAccountId should not be empty") + assert.Equal(t, parentUserName, sa.ParentUser, "ParentUser should match") + assert.Equal(t, "Test service account for CI/CD", sa.Description) + assert.Equal(t, "Active", sa.Status) + assert.NotEmpty(t, sa.AccessKeyId, "AccessKeyId should not be empty") + assert.NotEmpty(t, sa.SecretAccessKey, "SecretAccessKey should be returned on create") + assert.True(t, strings.HasPrefix(sa.AccessKeyId, "ABIA"), + "Service account AccessKeyId should have ABIA prefix") + + t.Logf("Created service account: ID=%s, AccessKeyId=%s", createdSAId, createdAccessKeyId) + }) + + t.Run("get_service_account", func(t *testing.T) { + require.NotEmpty(t, createdSAId, "Service account should have been created") + + resp, err := callIAMAPI(t, "GetServiceAccount", url.Values{ + "ServiceAccountId": {createdSAId}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var getResp GetServiceAccountResponse + err = xml.Unmarshal(body, &getResp) + require.NoError(t, err) + + sa := getResp.GetServiceAccountResult.ServiceAccount + assert.Equal(t, createdSAId, sa.ServiceAccountId) + assert.Equal(t, parentUserName, sa.ParentUser) + assert.Empty(t, sa.SecretAccessKey, "SecretAccessKey should not be returned on Get") + }) + + t.Run("list_service_accounts", func(t *testing.T) { + resp, err := callIAMAPI(t, "ListServiceAccounts", url.Values{ + "ParentUser": {parentUserName}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var listResp ListServiceAccountsResponse + err = xml.Unmarshal(body, &listResp) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(listResp.ListServiceAccountsResult.ServiceAccounts), 1, + "Should have at least one service account for the parent user") + }) + + t.Run("update_service_account_status", func(t *testing.T) { + require.NotEmpty(t, createdSAId) + + // Disable the service account + resp, err := callIAMAPI(t, "UpdateServiceAccount", url.Values{ + "ServiceAccountId": {createdSAId}, + "Status": {"Inactive"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify it's now inactive + getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{ + "ServiceAccountId": {createdSAId}, + }) + require.NoError(t, err) + defer getResp.Body.Close() + + body, err := io.ReadAll(getResp.Body) + require.NoError(t, err) + + var result GetServiceAccountResponse + err = xml.Unmarshal(body, &result) + require.NoError(t, err, "Failed to parse response: %s", string(body)) + + assert.Equal(t, "Inactive", result.GetServiceAccountResult.ServiceAccount.Status) + }) + + // Test that credentials could be used (verify they work with AWS SDK) + // This must run BEFORE delete_service_account to use valid credentials + t.Run("use_service_account_credentials", func(t *testing.T) { + require.NotEmpty(t, createdAccessKeyId) + require.NotEmpty(t, createdSecretAccessKey) + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(TestIAMEndpoint), // IAM and S3 usually on same port in mini-seaweed + Credentials: credentials.NewStaticCredentials( + createdAccessKeyId, + createdSecretAccessKey, + "", + ), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + }) + require.NoError(t, err) + + s3Client := s3.New(sess) + _, err = s3Client.ListBuckets(&s3.ListBucketsInput{}) + + // Note: we don't necessarily expect success if no buckets/permissions + // but we expect it not to fail with "InvalidAccessKeyId" or "SignatureDoesNotMatch" + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(), "Credentials should be valid") + assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(), "Signature should be valid") + } + } + }) + + t.Run("delete_service_account", func(t *testing.T) { + require.NotEmpty(t, createdSAId) + + resp, err := callIAMAPI(t, "DeleteServiceAccount", url.Values{ + "ServiceAccountId": {createdSAId}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify it no longer exists + getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{ + "ServiceAccountId": {createdSAId}, + }) + require.NoError(t, err) + defer getResp.Body.Close() + + // Should return an error (not found) + assert.NotEqual(t, http.StatusOK, getResp.StatusCode, + "GetServiceAccount should fail after deletion") + }) + +} + +// TestServiceAccountValidation tests validation of service account operations +func TestServiceAccountValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + t.Run("create_without_parent_user", func(t *testing.T) { + resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "Description": {"Test without parent"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "CreateServiceAccount without ParentUser should fail") + }) + + t.Run("create_with_nonexistent_parent", func(t *testing.T) { + resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{ + "ParentUser": {"nonexistent-user-12345"}, + "Description": {"Test with nonexistent parent"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "CreateServiceAccount with nonexistent parent should fail") + }) + + t.Run("get_nonexistent_service_account", func(t *testing.T) { + resp, err := callIAMAPI(t, "GetServiceAccount", url.Values{ + "ServiceAccountId": {"sa-NONEXISTENT123"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "GetServiceAccount for nonexistent ID should fail") + }) + + t.Run("delete_nonexistent_service_account", func(t *testing.T) { + resp, err := callIAMAPI(t, "DeleteServiceAccount", url.Values{ + "ServiceAccountId": {"sa-NONEXISTENT123"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "DeleteServiceAccount for nonexistent ID should fail") + }) +} + +// callIAMAPI is a helper to make IAM API calls +func callIAMAPI(t *testing.T, action string, params url.Values) (*http.Response, error) { + params.Set("Action", action) + + req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/", + strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 30 * time.Second} + return client.Do(req) +} + +// isSeaweedFSRunning checks if SeaweedFS S3 API is running +func isSeaweedFSRunning(t *testing.T) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(TestIAMEndpoint + "/status") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/test/s3/iam/s3_sts_test.go b/test/s3/iam/s3_sts_test.go new file mode 100644 index 000000000..84daf27a9 --- /dev/null +++ b/test/s3/iam/s3_sts_test.go @@ -0,0 +1,260 @@ +package iam + +import ( + "encoding/xml" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// STS API test constants +const ( + TestSTSEndpoint = "http://localhost:8333" +) + +// AssumeRoleWithWebIdentityResponse represents the STS response +type AssumeRoleWithWebIdentityTestResponse struct { + XMLName xml.Name `xml:"AssumeRoleWithWebIdentityResponse"` + Result struct { + Credentials struct { + AccessKeyId string `xml:"AccessKeyId"` + SecretAccessKey string `xml:"SecretAccessKey"` + SessionToken string `xml:"SessionToken"` + Expiration string `xml:"Expiration"` + } `xml:"Credentials"` + SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"` + } `xml:"AssumeRoleWithWebIdentityResult"` +} + +// STSErrorResponse represents an STS error response +type STSErrorTestResponse struct { + XMLName xml.Name `xml:"ErrorResponse"` + Error struct { + Type string `xml:"Type"` + Code string `xml:"Code"` + Message string `xml:"Message"` + } `xml:"Error"` + RequestId string `xml:"RequestId"` +} + +// TestAssumeRoleWithWebIdentityValidation tests input validation for the STS endpoint +func TestAssumeRoleWithWebIdentityValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSTSEndpointRunning(t) { + t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) + } + + t.Run("missing_web_identity_token", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::role/test-role"}, + "RoleSessionName": {"test-session"}, + // WebIdentityToken is missing + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Should fail without WebIdentityToken") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.Equal(t, "MissingParameter", errResp.Error.Code) + }) + + t.Run("missing_role_arn", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "WebIdentityToken": {"fake-jwt-token"}, + "RoleSessionName": {"test-session"}, + // RoleArn is missing + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Should fail without RoleArn") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.Equal(t, "MissingParameter", errResp.Error.Code) + }) + + t.Run("missing_role_session_name", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "WebIdentityToken": {"fake-jwt-token"}, + "RoleArn": {"arn:aws:iam::role/test-role"}, + // RoleSessionName is missing + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Should fail without RoleSessionName") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.Equal(t, "MissingParameter", errResp.Error.Code) + }) + + t.Run("invalid_jwt_token", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "WebIdentityToken": {"not-a-valid-jwt-token"}, + "RoleArn": {"arn:aws:iam::role/test-role"}, + "RoleSessionName": {"test-session"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Should fail with AccessDenied since the JWT is invalid + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "Should fail with invalid JWT token") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.Contains(t, []string{"AccessDenied", "InvalidParameterValue"}, errResp.Error.Code) + }) +} + +// TestAssumeRoleWithWebIdentityWithMockJWT tests the STS endpoint with mock JWTs +// This test requires the mock OIDC provider to be configured +func TestAssumeRoleWithWebIdentityWithMockJWT(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isSTSEndpointRunning(t) { + t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) + } + + // Create a test framework to get valid JWT tokens + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + // Generate a test JWT using the framework + testUsername := "sts-test-user" + testRole := "readonly" + + // Try to get a token - use Keycloak if available, otherwise generate a mock JWT + var token string + var err error + if framework.useKeycloak { + token, err = framework.getKeycloakToken(testUsername) + } else { + // Generate a mock JWT token with 1 hour validity + token, err = framework.generateJWTToken(testUsername, testRole, time.Hour) + } + if err != nil { + t.Skipf("Unable to generate test JWT (requires mock OIDC or Keycloak): %v", err) + } + + t.Run("valid_jwt_token", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "WebIdentityToken": {token}, + "RoleArn": {"arn:aws:iam::role/" + testRole}, + "RoleSessionName": {"integration-test-session"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) + + // Note: This may still fail if the role/trust policy is not configured + // In that case, we just verify the error is about trust policy, not token validation + if resp.StatusCode != http.StatusOK { + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.NotEqual(t, "InvalidParameterValue", errResp.Error.Code, + "Token validation should not fail - error should be about trust policy") + } else { + var stsResp AssumeRoleWithWebIdentityTestResponse + err = xml.Unmarshal(body, &stsResp) + require.NoError(t, err, "Failed to parse response: %s", string(body)) + + creds := stsResp.Result.Credentials + assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty") + assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty") + assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty") + assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty") + + t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId) + } + }) + + t.Run("with_duration_seconds", func(t *testing.T) { + resp, err := callSTSAPI(t, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "WebIdentityToken": {token}, + "RoleArn": {"arn:aws:iam::role/" + testRole}, + "RoleSessionName": {"integration-test-session"}, + "DurationSeconds": {"3600"}, // 1 hour + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the request is accepted (even if trust policy causes rejection) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Should not fail with InvalidParameterValue for DurationSeconds + if resp.StatusCode != http.StatusOK { + var errResp STSErrorTestResponse + err = xml.Unmarshal(body, &errResp) + require.NoError(t, err, "Failed to parse error response: %s", string(body)) + assert.NotContains(t, errResp.Error.Message, "DurationSeconds", + "DurationSeconds parameter should be accepted") + } + }) +} + +// callSTSAPI is a helper to make STS API calls +func callSTSAPI(t *testing.T, params url.Values) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/", + strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 30 * time.Second} + return client.Do(req) +} + +// isSTSEndpointRunning checks if SeaweedFS STS endpoint is running +func isSTSEndpointRunning(t *testing.T) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(TestSTSEndpoint + "/status") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/weed/iam/oidc/oidc_provider.go b/weed/iam/oidc/oidc_provider.go index fe1cdaccb..f39a74a34 100644 --- a/weed/iam/oidc/oidc_provider.go +++ b/weed/iam/oidc/oidc_provider.go @@ -7,6 +7,7 @@ import ( "crypto/rsa" "encoding/base64" "encoding/json" + "errors" "fmt" "math/big" "net/http" @@ -326,17 +327,21 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid }) if err != nil { - return nil, fmt.Errorf("failed to validate JWT token: %v", err) + // Use JWT library's typed errors for robust error checking + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, fmt.Errorf("%w: %v", providers.ErrProviderTokenExpired, err) + } + return nil, fmt.Errorf("%w: %v", providers.ErrProviderInvalidToken, err) } if !validatedToken.Valid { - return nil, fmt.Errorf("JWT token is invalid") + return nil, fmt.Errorf("%w: token validation failed", providers.ErrProviderInvalidToken) } // Validate required claims issuer, ok := claims["iss"].(string) if !ok || issuer != p.config.Issuer { - return nil, fmt.Errorf("invalid or missing issuer claim") + return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer) } // Check audience claim (aud) or authorized party (azp) - Keycloak uses azp @@ -365,12 +370,12 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid } if !audienceMatched { - return nil, fmt.Errorf("invalid or missing audience claim for client ID %s", p.config.ClientID) + return nil, fmt.Errorf("%w: expected client ID %s", providers.ErrProviderInvalidAudience, p.config.ClientID) } subject, ok := claims["sub"].(string) if !ok { - return nil, fmt.Errorf("missing subject claim") + return nil, fmt.Errorf("%w: missing subject claim", providers.ErrProviderMissingClaims) } // Convert to our TokenClaims structure diff --git a/weed/iam/providers/errors.go b/weed/iam/providers/errors.go new file mode 100644 index 000000000..eeac47c52 --- /dev/null +++ b/weed/iam/providers/errors.go @@ -0,0 +1,22 @@ +package providers + +import "errors" + +// Typed errors for identity provider operations +// These enable robust error checking with errors.Is() throughout the stack +var ( + // ErrProviderTokenExpired indicates that the provided token has expired + ErrProviderTokenExpired = errors.New("provider: token has expired") + + // ErrProviderInvalidToken indicates that the token format is invalid or malformed + ErrProviderInvalidToken = errors.New("provider: invalid token format") + + // ErrProviderInvalidIssuer indicates that the token issuer is not trusted + ErrProviderInvalidIssuer = errors.New("provider: invalid token issuer") + + // ErrProviderInvalidAudience indicates that the token audience doesn't match expected value + ErrProviderInvalidAudience = errors.New("provider: invalid token audience") + + // ErrProviderMissingClaims indicates that required claims are missing from the token + ErrProviderMissingClaims = errors.New("provider: missing required claims") +) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 47ec7b8c4..07a42b45a 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -150,3 +150,55 @@ type UpdateAccessKeyResponse struct { CommonResponse XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"` } + +// ServiceAccountInfo contains service account details for API responses. +type ServiceAccountInfo struct { + ServiceAccountId string `xml:"ServiceAccountId"` + ParentUser string `xml:"ParentUser"` + Description string `xml:"Description,omitempty"` + AccessKeyId string `xml:"AccessKeyId"` + SecretAccessKey *string `xml:"SecretAccessKey,omitempty"` // Only returned in Create response + Status string `xml:"Status"` + Expiration *string `xml:"Expiration,omitempty"` // ISO 8601 format, nil = no expiration + CreateDate string `xml:"CreateDate"` +} + +// CreateServiceAccountResponse is the response for CreateServiceAccount action. +type CreateServiceAccountResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateServiceAccountResponse"` + CreateServiceAccountResult struct { + ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"` + } `xml:"CreateServiceAccountResult"` +} + +// DeleteServiceAccountResponse is the response for DeleteServiceAccount action. +type DeleteServiceAccountResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteServiceAccountResponse"` +} + +// ListServiceAccountsResponse is the response for ListServiceAccounts action. +type ListServiceAccountsResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListServiceAccountsResponse"` + ListServiceAccountsResult struct { + ServiceAccounts []*ServiceAccountInfo `xml:"ServiceAccounts>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"ListServiceAccountsResult"` +} + +// GetServiceAccountResponse is the response for GetServiceAccount action. +type GetServiceAccountResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetServiceAccountResponse"` + GetServiceAccountResult struct { + ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"` + } `xml:"GetServiceAccountResult"` +} + +// UpdateServiceAccountResponse is the response for UpdateServiceAccount action. +type UpdateServiceAccountResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateServiceAccountResponse"` +} diff --git a/weed/iam/sts/constants.go b/weed/iam/sts/constants.go index 0d2afc59e..1f74668eb 100644 --- a/weed/iam/sts/constants.go +++ b/weed/iam/sts/constants.go @@ -1,5 +1,9 @@ package sts +import ( + "errors" +) + // Store Types const ( StoreTypeMemory = "memory" @@ -77,6 +81,25 @@ const ( ErrMissingSessionID = "missing session ID" ) +// Typed errors for robust error checking with errors.Is() +// These enable the HTTP layer to use errors.Is() instead of fragile string matching +var ( + // ErrTokenExpired indicates that the provided token has expired + ErrTypedTokenExpired = errors.New("token has expired") + + // ErrTypedInvalidToken indicates that the token format is invalid or malformed + ErrTypedInvalidToken = errors.New("invalid token format") + + // ErrTypedInvalidIssuer indicates that the token issuer is not trusted + ErrTypedInvalidIssuer = errors.New("invalid token issuer") + + // ErrTypedInvalidAudience indicates that the token audience doesn't match expected value + ErrTypedInvalidAudience = errors.New("invalid token audience") + + // ErrTypedMissingClaims indicates that required claims are missing from the token + ErrTypedMissingClaims = errors.New("missing required claims") +) + // JWT Claims const ( JWTClaimIssuer = "iss" diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index e28340f30..fc7285a37 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -3,6 +3,7 @@ package sts import ( "context" "encoding/json" + "errors" "fmt" "strconv" "time" @@ -634,6 +635,20 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string) // Authenticate with the correct provider for this issuer identity, err := provider.Authenticate(ctx, token) if err != nil { + // Map provider errors to STS errors using errors.Is() for robust error checking + // This eliminates fragile string matching and provides reliable error classification + if errors.Is(err, providers.ErrProviderTokenExpired) { + return nil, nil, fmt.Errorf("%w: %v", ErrTypedTokenExpired, err) + } else if errors.Is(err, providers.ErrProviderInvalidToken) { + return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidToken, err) + } else if errors.Is(err, providers.ErrProviderInvalidIssuer) { + return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidIssuer, err) + } else if errors.Is(err, providers.ErrProviderInvalidAudience) { + return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidAudience, err) + } else if errors.Is(err, providers.ErrProviderMissingClaims) { + return nil, nil, fmt.Errorf("%w: %v", ErrTypedMissingClaims, err) + } + // For other errors, return with context return nil, nil, fmt.Errorf("token validation failed with provider for issuer %s: %w", issuer, err) } diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index c16b1f79b..712e4196e 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -9,18 +9,24 @@ import ( // Type aliases for IAM response types from shared package type ( - CommonResponse = iamlib.CommonResponse - ListUsersResponse = iamlib.ListUsersResponse - ListAccessKeysResponse = iamlib.ListAccessKeysResponse - DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse - CreatePolicyResponse = iamlib.CreatePolicyResponse - CreateUserResponse = iamlib.CreateUserResponse - DeleteUserResponse = iamlib.DeleteUserResponse - GetUserResponse = iamlib.GetUserResponse - UpdateUserResponse = iamlib.UpdateUserResponse - CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse - PutUserPolicyResponse = iamlib.PutUserPolicyResponse - DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse - GetUserPolicyResponse = iamlib.GetUserPolicyResponse - ErrorResponse = iamlib.ErrorResponse + CommonResponse = iamlib.CommonResponse + ListUsersResponse = iamlib.ListUsersResponse + ListAccessKeysResponse = iamlib.ListAccessKeysResponse + DeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse + CreatePolicyResponse = iamlib.CreatePolicyResponse + CreateUserResponse = iamlib.CreateUserResponse + DeleteUserResponse = iamlib.DeleteUserResponse + GetUserResponse = iamlib.GetUserResponse + UpdateUserResponse = iamlib.UpdateUserResponse + CreateAccessKeyResponse = iamlib.CreateAccessKeyResponse + PutUserPolicyResponse = iamlib.PutUserPolicyResponse + DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse + GetUserPolicyResponse = iamlib.GetUserPolicyResponse + ErrorResponse = iamlib.ErrorResponse + ServiceAccountInfo = iamlib.ServiceAccountInfo + CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse + DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse + ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse + GetServiceAccountResponse = iamlib.GetServiceAccountResponse + UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse ) diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto index 342063f8d..6720a0456 100644 --- a/weed/pb/iam.proto +++ b/weed/pb/iam.proto @@ -17,6 +17,7 @@ service SeaweedIdentityAccessManagement { message S3ApiConfiguration { repeated Identity identities = 1; repeated Account accounts = 2; + repeated ServiceAccount service_accounts = 3; } message Identity { @@ -25,6 +26,7 @@ message Identity { repeated string actions = 3; Account account = 4; bool disabled = 5; // User status: false = enabled (default), true = disabled + repeated string service_account_ids = 6; // IDs of service accounts owned by this user } message Credential { @@ -39,6 +41,20 @@ message Account { string email_address = 3; } +// ServiceAccount represents a service account - special credentials for applications. +// Service accounts are linked to a parent user and can have restricted permissions. +message ServiceAccount { + string id = 1; // Unique identifier (e.g., "sa-xxxxx") + string parent_user = 2; // Parent identity name + string description = 3; // Optional description + Credential credential = 4; // Access key/secret for this service account + repeated string actions = 5; // Allowed actions (subset of parent) + int64 expiration = 6; // Unix timestamp, 0 = no expiration + bool disabled = 7; // Status: false = enabled (default) + int64 created_at = 8; // Creation timestamp + string created_by = 9; // Who created this service account +} + /* message Policy { repeated Statement statements = 1; diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go index 8eeaf8488..b40dc486a 100644 --- a/weed/pb/iam_pb/iam.pb.go +++ b/weed/pb/iam_pb/iam.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: iam.proto package iam_pb @@ -22,11 +22,12 @@ const ( ) type S3ApiConfiguration struct { - state protoimpl.MessageState `protogen:"open.v1"` - Identities []*Identity `protobuf:"bytes,1,rep,name=identities,proto3" json:"identities,omitempty"` - Accounts []*Account `protobuf:"bytes,2,rep,name=accounts,proto3" json:"accounts,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Identities []*Identity `protobuf:"bytes,1,rep,name=identities,proto3" json:"identities,omitempty"` + Accounts []*Account `protobuf:"bytes,2,rep,name=accounts,proto3" json:"accounts,omitempty"` + ServiceAccounts []*ServiceAccount `protobuf:"bytes,3,rep,name=service_accounts,json=serviceAccounts,proto3" json:"service_accounts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *S3ApiConfiguration) Reset() { @@ -73,15 +74,23 @@ func (x *S3ApiConfiguration) GetAccounts() []*Account { return nil } +func (x *S3ApiConfiguration) GetServiceAccounts() []*ServiceAccount { + if x != nil { + return x.ServiceAccounts + } + return nil +} + type Identity struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Credentials []*Credential `protobuf:"bytes,2,rep,name=credentials,proto3" json:"credentials,omitempty"` - Actions []string `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"` - Account *Account `protobuf:"bytes,4,opt,name=account,proto3" json:"account,omitempty"` - Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Credentials []*Credential `protobuf:"bytes,2,rep,name=credentials,proto3" json:"credentials,omitempty"` + Actions []string `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"` + Account *Account `protobuf:"bytes,4,opt,name=account,proto3" json:"account,omitempty"` + Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled + ServiceAccountIds []string `protobuf:"bytes,6,rep,name=service_account_ids,json=serviceAccountIds,proto3" json:"service_account_ids,omitempty"` // IDs of service accounts owned by this user + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Identity) Reset() { @@ -149,6 +158,13 @@ func (x *Identity) GetDisabled() bool { return false } +func (x *Identity) GetServiceAccountIds() []string { + if x != nil { + return x.ServiceAccountIds + } + return nil +} + type Credential struct { state protoimpl.MessageState `protogen:"open.v1"` AccessKey string `protobuf:"bytes,1,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` @@ -269,22 +285,134 @@ func (x *Account) GetEmailAddress() string { return "" } +// ServiceAccount represents a service account - special credentials for applications. +// Service accounts are linked to a parent user and can have restricted permissions. +type ServiceAccount struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Unique identifier (e.g., "sa-xxxxx") + ParentUser string `protobuf:"bytes,2,opt,name=parent_user,json=parentUser,proto3" json:"parent_user,omitempty"` // Parent identity name + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` // Optional description + Credential *Credential `protobuf:"bytes,4,opt,name=credential,proto3" json:"credential,omitempty"` // Access key/secret for this service account + Actions []string `protobuf:"bytes,5,rep,name=actions,proto3" json:"actions,omitempty"` // Allowed actions (subset of parent) + Expiration int64 `protobuf:"varint,6,opt,name=expiration,proto3" json:"expiration,omitempty"` // Unix timestamp, 0 = no expiration + Disabled bool `protobuf:"varint,7,opt,name=disabled,proto3" json:"disabled,omitempty"` // Status: false = enabled (default) + CreatedAt int64 `protobuf:"varint,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Creation timestamp + CreatedBy string `protobuf:"bytes,9,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` // Who created this service account + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceAccount) Reset() { + *x = ServiceAccount{} + mi := &file_iam_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceAccount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceAccount) ProtoMessage() {} + +func (x *ServiceAccount) ProtoReflect() protoreflect.Message { + mi := &file_iam_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceAccount.ProtoReflect.Descriptor instead. +func (*ServiceAccount) Descriptor() ([]byte, []int) { + return file_iam_proto_rawDescGZIP(), []int{4} +} + +func (x *ServiceAccount) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ServiceAccount) GetParentUser() string { + if x != nil { + return x.ParentUser + } + return "" +} + +func (x *ServiceAccount) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ServiceAccount) GetCredential() *Credential { + if x != nil { + return x.Credential + } + return nil +} + +func (x *ServiceAccount) GetActions() []string { + if x != nil { + return x.Actions + } + return nil +} + +func (x *ServiceAccount) GetExpiration() int64 { + if x != nil { + return x.Expiration + } + return 0 +} + +func (x *ServiceAccount) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + +func (x *ServiceAccount) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *ServiceAccount) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + var File_iam_proto protoreflect.FileDescriptor const file_iam_proto_rawDesc = "" + "\n" + - "\tiam.proto\x12\x06iam_pb\"s\n" + + "\tiam.proto\x12\x06iam_pb\"\xb6\x01\n" + "\x12S3ApiConfiguration\x120\n" + "\n" + "identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" + "identities\x12+\n" + - "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\"\xb5\x01\n" + + "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\x12A\n" + + "\x10service_accounts\x18\x03 \x03(\v2\x16.iam_pb.ServiceAccountR\x0fserviceAccounts\"\xe5\x01\n" + "\bIdentity\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + "\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" + "\aactions\x18\x03 \x03(\tR\aactions\x12)\n" + "\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\x12\x1a\n" + - "\bdisabled\x18\x05 \x01(\bR\bdisabled\"b\n" + + "\bdisabled\x18\x05 \x01(\bR\bdisabled\x12.\n" + + "\x13service_account_ids\x18\x06 \x03(\tR\x11serviceAccountIds\"b\n" + "\n" + "Credential\x12\x1d\n" + "\n" + @@ -295,7 +423,24 @@ const file_iam_proto_rawDesc = "" + "\aAccount\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12#\n" + - "\remail_address\x18\x03 \x01(\tR\femailAddress2!\n" + + "\remail_address\x18\x03 \x01(\tR\femailAddress\"\xab\x02\n" + + "\x0eServiceAccount\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1f\n" + + "\vparent_user\x18\x02 \x01(\tR\n" + + "parentUser\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x122\n" + + "\n" + + "credential\x18\x04 \x01(\v2\x12.iam_pb.CredentialR\n" + + "credential\x12\x18\n" + + "\aactions\x18\x05 \x03(\tR\aactions\x12\x1e\n" + + "\n" + + "expiration\x18\x06 \x01(\x03R\n" + + "expiration\x12\x1a\n" + + "\bdisabled\x18\a \x01(\bR\bdisabled\x12\x1d\n" + + "\n" + + "created_at\x18\b \x01(\x03R\tcreatedAt\x12\x1d\n" + + "\n" + + "created_by\x18\t \x01(\tR\tcreatedBy2!\n" + "\x1fSeaweedIdentityAccessManagementBK\n" + "\x10seaweedfs.clientB\bIamProtoZ-github.com/seaweedfs/seaweedfs/weed/pb/iam_pbb\x06proto3" @@ -311,23 +456,26 @@ func file_iam_proto_rawDescGZIP() []byte { return file_iam_proto_rawDescData } -var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_iam_proto_goTypes = []any{ (*S3ApiConfiguration)(nil), // 0: iam_pb.S3ApiConfiguration (*Identity)(nil), // 1: iam_pb.Identity (*Credential)(nil), // 2: iam_pb.Credential (*Account)(nil), // 3: iam_pb.Account + (*ServiceAccount)(nil), // 4: iam_pb.ServiceAccount } var file_iam_proto_depIdxs = []int32{ 1, // 0: iam_pb.S3ApiConfiguration.identities:type_name -> iam_pb.Identity 3, // 1: iam_pb.S3ApiConfiguration.accounts:type_name -> iam_pb.Account - 2, // 2: iam_pb.Identity.credentials:type_name -> iam_pb.Credential - 3, // 3: iam_pb.Identity.account:type_name -> iam_pb.Account - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 4, // 2: iam_pb.S3ApiConfiguration.service_accounts:type_name -> iam_pb.ServiceAccount + 2, // 3: iam_pb.Identity.credentials:type_name -> iam_pb.Credential + 3, // 4: iam_pb.Identity.account:type_name -> iam_pb.Account + 2, // 5: iam_pb.ServiceAccount.credential:type_name -> iam_pb.Credential + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_iam_proto_init() } @@ -341,7 +489,7 @@ func file_iam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iam_proto_rawDesc), len(file_iam_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/weed/pb/iam_pb/iam_grpc.pb.go b/weed/pb/iam_pb/iam_grpc.pb.go index 5ca4a2293..12e70e9b6 100644 --- a/weed/pb/iam_pb/iam_grpc.pb.go +++ b/weed/pb/iam_pb/iam_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: iam.proto package iam_pb diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 5acd711cd..49f2acf87 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/filer" @@ -100,27 +101,15 @@ var ( ) type Credential struct { - AccessKey string - SecretKey string - Status string // Access key status: "Active" or "Inactive" (empty treated as "Active") -} - -// "Permission": "FULL_CONTROL"|"WRITE"|"WRITE_ACP"|"READ"|"READ_ACP" -func (action Action) getPermission() Permission { - switch act := strings.Split(string(action), ":")[0]; act { - case s3_constants.ACTION_ADMIN: - return Permission("FULL_CONTROL") - case s3_constants.ACTION_WRITE: - return Permission("WRITE") - case s3_constants.ACTION_WRITE_ACP: - return Permission("WRITE_ACP") - case s3_constants.ACTION_READ: - return Permission("READ") - case s3_constants.ACTION_READ_ACP: - return Permission("READ_ACP") - default: - return Permission("") - } + AccessKey string + SecretKey string + Status string // Access key status: "Active" or "Inactive" (empty treated as "Active") + Expiration int64 // Unix timestamp when credential expires (0 = no expiration) +} + +// isCredentialExpired checks if a credential has expired +func (c *Credential) isCredentialExpired() bool { + return c.Expiration > 0 && c.Expiration < time.Now().Unix() } func NewIdentityAccessManagement(option *S3ApiServerOption) *IdentityAccessManagement { @@ -358,6 +347,37 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api nameToIdentity[t.Name] = t } + // Load service accounts and add their credentials to the parent identity + for _, sa := range config.ServiceAccounts { + if sa.Credential == nil { + continue + } + + // Skip disabled service accounts - they should not be able to authenticate + if sa.Disabled { + glog.V(3).Infof("Skipping disabled service account %s", sa.Id) + continue + } + + // Find the parent identity + parentIdent, ok := nameToIdentity[sa.ParentUser] + if !ok { + glog.Warningf("Service account %s has non-existent parent user %s, skipping", sa.Id, sa.ParentUser) + continue + } + + // Add service account credential to parent identity with expiration + cred := &Credential{ + AccessKey: sa.Credential.AccessKey, + SecretKey: sa.Credential.SecretKey, + Status: sa.Credential.Status, + Expiration: sa.Expiration, // Populate expiration from service account + } + parentIdent.Credentials = append(parentIdent.Credentials, cred) + accessKeyIdent[sa.Credential.AccessKey] = parentIdent + glog.V(3).Infof("Loaded service account %s for parent %s (expiration: %d)", sa.Id, sa.ParentUser, sa.Expiration) + } + iam.m.Lock() // atomically switch iam.identities = identities diff --git a/weed/s3api/auth_signature_v2.go b/weed/s3api/auth_signature_v2.go index 35397f940..bd0997d93 100644 --- a/weed/s3api/auth_signature_v2.go +++ b/weed/s3api/auth_signature_v2.go @@ -88,6 +88,13 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http. return s3err.ErrInvalidAccessKeyID } + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + accessKey, cred.Expiration, time.Now().Unix()) + return s3err.ErrAccessDenied + } + bucket := formValues.Get("bucket") if !identity.canDo(s3_constants.ACTION_WRITE, bucket, "") { return s3err.ErrAccessDenied @@ -133,6 +140,13 @@ func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity return nil, s3err.ErrInvalidAccessKeyID } + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + accessKey, cred.Expiration, time.Now().Unix()) + return nil, s3err.ErrAccessDenied + } + expectedAuth := signatureV2(cred, r.Method, r.URL.Path, r.URL.Query().Encode(), r.Header) // Extract signatures from both auth headers @@ -203,6 +217,13 @@ func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request return nil, s3err.ErrInvalidAccessKeyID } + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + accessKey, cred.Expiration, time.Now().Unix()) + return nil, s3err.ErrAccessDenied + } + expectedSignature := preSignatureV2(cred, r.Method, r.URL.Path, r.URL.Query().Encode(), r.Header, expires) if !compareSignatureV2(signature, expectedSignature) { return nil, s3err.ErrSignatureDoesNotMatch diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 4e22530d1..13cd26b71 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -226,6 +226,13 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID } + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + authInfo.AccessKey, cred.Expiration, time.Now().Unix()) + return nil, nil, "", nil, s3err.ErrAccessDenied + } + // 3. Perform permission check if shouldCheckPermissions { bucket, object := s3_constants.GetBucketAndObject(r) @@ -570,6 +577,13 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http. return s3err.ErrInvalidAccessKeyID } + // Check service account expiration + if cred.isCredentialExpired() { + glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)", + credHeader.accessKey, cred.Expiration, time.Now().Unix()) + return s3err.ErrAccessDenied + } + bucket := formValues.Get("bucket") if !identity.canDo(s3_constants.ACTION_WRITE, bucket, "") { return s3err.ErrAccessDenied diff --git a/weed/s3api/chunked_reader_v4_test.go b/weed/s3api/chunked_reader_v4_test.go index 98654ce8b..3da13a71a 100644 --- a/weed/s3api/chunked_reader_v4_test.go +++ b/weed/s3api/chunked_reader_v4_test.go @@ -25,7 +25,7 @@ func getDefaultTimestamp() string { const ( defaultTimestamp = "20130524T000000Z" // Legacy constant for reference defaultBucketName = "examplebucket" - defaultAccessKeyId = "AKIAIOSFODNN7EXAMPLE" + defaultAccessKeyId = UserAccessKeyPrefix + "IOSFODNN7EXAMPLE" defaultSecretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" defaultRegion = "us-east-1" ) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index d7a1c1575..08e808f6c 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -10,8 +10,10 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "sync" + "time" "github.com/aws/aws-sdk-go/service/iam" "github.com/seaweedfs/seaweedfs/weed/credential" @@ -20,6 +22,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" . "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "google.golang.org/protobuf/proto" @@ -41,9 +44,22 @@ func NewEmbeddedIamApi(credentialManager *credential.CredentialManager, iam *Ide } } +// Constants for service account identifiers +const ( + ServiceAccountIDLength = 12 // Length of the service account ID + AccessKeyLength = 20 // AWS standard access key length + SecretKeyLength = 40 // AWS standard secret key length (base64 encoded) + ServiceAccountIDPrefix = "sa" + ServiceAccountKeyPrefix = "ABIA" // Service account access keys start with ABIA + UserAccessKeyPrefix = "AKIA" // User access keys start with AKIA + + // Operational limits (AWS IAM compatible) + MaxServiceAccountsPerUser = 100 // Maximum service accounts per user + MaxDescriptionLength = 1000 // Maximum description length in characters +) + // Type aliases for IAM response types from shared package type ( - iamCommonResponse = iamlib.CommonResponse iamListUsersResponse = iamlib.ListUsersResponse iamListAccessKeysResponse = iamlib.ListAccessKeysResponse iamDeleteAccessKeyResponse = iamlib.DeleteAccessKeyResponse @@ -60,6 +76,13 @@ type ( iamUpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse iamErrorResponse = iamlib.ErrorResponse iamError = iamlib.Error + // Service account response types + iamServiceAccountInfo = iamlib.ServiceAccountInfo + iamCreateServiceAccountResponse = iamlib.CreateServiceAccountResponse + iamDeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse + iamListServiceAccountsResponse = iamlib.ListServiceAccountsResponse + iamGetServiceAccountResponse = iamlib.GetServiceAccountResponse + iamUpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse ) // Helper function wrappers using shared package @@ -217,6 +240,15 @@ func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName s var resp iamDeleteUserResponse for i, ident := range s3cfg.Identities { if userName == ident.Name { + // AWS IAM behavior: prevent deletion if user has service accounts + // This ensures explicit cleanup and prevents orphaned resources + if len(ident.ServiceAccountIds) > 0 { + return resp, &iamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("cannot delete user %s: user has %d service account(s). Delete service accounts first", + userName, len(ident.ServiceAccountIds)), + } + } s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...) return resp, nil } @@ -260,11 +292,14 @@ func (e *EmbeddedIamApi) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, value userName := values.Get("UserName") status := iam.StatusTypeActive - accessKeyId, err := iamStringWithCharset(21, iamCharsetUpper) + // Generate AWS-standard access key: AKIA prefix + 16 random uppercase chars = 20 total + randomPart, err := iamStringWithCharset(AccessKeyLength-len(UserAccessKeyPrefix), iamCharsetUpper) if err != nil { return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)} } - secretAccessKey, err := iamStringWithCharset(42, iamCharset) + accessKeyId := UserAccessKeyPrefix + randomPart + + secretAccessKey, err := iamStringWithCharset(SecretKeyLength, iamCharset) if err != nil { return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)} } @@ -565,6 +600,291 @@ func (e *EmbeddedIamApi) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, value return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} } +// findIdentityByName is a helper function to find an identity by name. +// Returns the identity or nil if not found. +func findIdentityByName(s3cfg *iam_pb.S3ApiConfiguration, name string) *iam_pb.Identity { + for _, ident := range s3cfg.Identities { + if ident.Name == name { + return ident + } + } + return nil +} + +// CreateServiceAccount creates a new service account for a user. +func (e *EmbeddedIamApi) CreateServiceAccount(s3cfg *iam_pb.S3ApiConfiguration, values url.Values, createdBy string) (iamCreateServiceAccountResponse, *iamError) { + var resp iamCreateServiceAccountResponse + parentUser := values.Get("ParentUser") + description := values.Get("Description") + expirationStr := values.Get("Expiration") // Unix timestamp as string + + if parentUser == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("ParentUser is required")} + } + + // Validate description length + if len(description) > MaxDescriptionLength { + return resp, &iamError{ + Code: iam.ErrCodeInvalidInputException, + Error: fmt.Errorf("description exceeds maximum length of %d characters", MaxDescriptionLength), + } + } + + // Verify parent user exists + parentIdent := findIdentityByName(s3cfg, parentUser) + if parentIdent == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, parentUser)} + } + + // Check service account limit per user + if len(parentIdent.ServiceAccountIds) >= MaxServiceAccountsPerUser { + return resp, &iamError{ + Code: iam.ErrCodeLimitExceededException, + Error: fmt.Errorf("user %s has reached the maximum limit of %d service accounts", + parentUser, MaxServiceAccountsPerUser), + } + } + + // Generate unique ID and credentials + saId, err := iamStringWithCharset(ServiceAccountIDLength, iamCharsetUpper) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate ID: %w", err)} + } + saId = ServiceAccountIDPrefix + "-" + saId + + // Generate access key ID with correct length (20 chars total including prefix) + // AWS access keys are always 20 characters: 4-char prefix (ABIA) + 16 random chars + accessKeyId, err := iamStringWithCharset(AccessKeyLength-len(ServiceAccountKeyPrefix), iamCharsetUpper) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)} + } + accessKeyId = ServiceAccountKeyPrefix + accessKeyId + + secretAccessKey, err := iamStringWithCharset(SecretKeyLength, iamCharset) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)} + } + + // Parse expiration if provided + var expiration int64 + if expirationStr != "" { + var err error + expiration, err = strconv.ParseInt(expirationStr, 10, 64) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("invalid expiration format: %w", err)} + } + if expiration > 0 && expiration < time.Now().Unix() { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("expiration must be in the future")} + } + } + + now := time.Now() + + // Copy parent's actions to avoid shared slice reference + actions := make([]string, len(parentIdent.Actions)) + copy(actions, parentIdent.Actions) + + sa := &iam_pb.ServiceAccount{ + Id: saId, + ParentUser: parentUser, + Description: description, + Credential: &iam_pb.Credential{ + AccessKey: accessKeyId, + SecretKey: secretAccessKey, + Status: iamAccessKeyStatusActive, + }, + Actions: actions, // Independent copy of parent's actions + Expiration: expiration, + Disabled: false, + CreatedAt: now.Unix(), + CreatedBy: createdBy, + } + + s3cfg.ServiceAccounts = append(s3cfg.ServiceAccounts, sa) + parentIdent.ServiceAccountIds = append(parentIdent.ServiceAccountIds, saId) + + // Build response + resp.CreateServiceAccountResult.ServiceAccount = iamServiceAccountInfo{ + ServiceAccountId: saId, + ParentUser: parentUser, + Description: description, + AccessKeyId: accessKeyId, + SecretAccessKey: &secretAccessKey, + Status: iamAccessKeyStatusActive, + CreateDate: now.Format(time.RFC3339), + } + if expiration > 0 { + expStr := time.Unix(expiration, 0).Format(time.RFC3339) + resp.CreateServiceAccountResult.ServiceAccount.Expiration = &expStr + } + + return resp, nil +} + +// DeleteServiceAccount deletes a service account. +func (e *EmbeddedIamApi) DeleteServiceAccount(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamDeleteServiceAccountResponse, *iamError) { + var resp iamDeleteServiceAccountResponse + saId := values.Get("ServiceAccountId") + + if saId == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("ServiceAccountId is required")} + } + + // Find and remove the service account + for i, sa := range s3cfg.ServiceAccounts { + if sa.Id == saId { + // Remove from parent's list + if parentIdent := findIdentityByName(s3cfg, sa.ParentUser); parentIdent != nil { + // Remove service account ID from parent's list using filter pattern + // This avoids mutating the slice during iteration + filtered := parentIdent.ServiceAccountIds[:0] + for _, id := range parentIdent.ServiceAccountIds { + if id != saId { + filtered = append(filtered, id) + } + } + parentIdent.ServiceAccountIds = filtered + } + // Remove service account + s3cfg.ServiceAccounts = append(s3cfg.ServiceAccounts[:i], s3cfg.ServiceAccounts[i+1:]...) + return resp, nil + } + } + + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("service account %s not found", saId)} +} + +// ListServiceAccounts lists service accounts, optionally filtered by parent user. +func (e *EmbeddedIamApi) ListServiceAccounts(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) iamListServiceAccountsResponse { + var resp iamListServiceAccountsResponse + parentUser := values.Get("ParentUser") // Optional filter + + for _, sa := range s3cfg.ServiceAccounts { + if parentUser != "" && sa.ParentUser != parentUser { + continue + } + if sa.Credential == nil { + glog.Warningf("Service account %s has nil credential, skipping", sa.Id) + continue + } + status := iamAccessKeyStatusActive + if sa.Disabled { + status = iamAccessKeyStatusInactive + } + info := &iamServiceAccountInfo{ + ServiceAccountId: sa.Id, + ParentUser: sa.ParentUser, + Description: sa.Description, + AccessKeyId: sa.Credential.AccessKey, + Status: status, + CreateDate: time.Unix(sa.CreatedAt, 0).Format(time.RFC3339), + } + if sa.Expiration > 0 { + expStr := time.Unix(sa.Expiration, 0).Format(time.RFC3339) + info.Expiration = &expStr + } + resp.ListServiceAccountsResult.ServiceAccounts = append(resp.ListServiceAccountsResult.ServiceAccounts, info) + } + + return resp +} + +// GetServiceAccount retrieves a service account by ID. +func (e *EmbeddedIamApi) GetServiceAccount(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamGetServiceAccountResponse, *iamError) { + var resp iamGetServiceAccountResponse + saId := values.Get("ServiceAccountId") + + if saId == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("ServiceAccountId is required")} + } + + for _, sa := range s3cfg.ServiceAccounts { + if sa.Id == saId { + if sa.Credential == nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("service account %s has no credentials", saId)} + } + status := iamAccessKeyStatusActive + if sa.Disabled { + status = iamAccessKeyStatusInactive + } + resp.GetServiceAccountResult.ServiceAccount = iamServiceAccountInfo{ + ServiceAccountId: sa.Id, + ParentUser: sa.ParentUser, + Description: sa.Description, + AccessKeyId: sa.Credential.AccessKey, + Status: status, + CreateDate: time.Unix(sa.CreatedAt, 0).Format(time.RFC3339), + } + if sa.Expiration > 0 { + expStr := time.Unix(sa.Expiration, 0).Format(time.RFC3339) + resp.GetServiceAccountResult.ServiceAccount.Expiration = &expStr + } + return resp, nil + } + } + + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("service account %s not found", saId)} +} + +// UpdateServiceAccount updates a service account's status, description, or expiration. +func (e *EmbeddedIamApi) UpdateServiceAccount(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamUpdateServiceAccountResponse, *iamError) { + var resp iamUpdateServiceAccountResponse + saId := values.Get("ServiceAccountId") + newStatus := values.Get("Status") + newDescription := values.Get("Description") + newExpirationStr := values.Get("Expiration") + + if saId == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("ServiceAccountId is required")} + } + + for _, sa := range s3cfg.ServiceAccounts { + if sa.Id == saId { + // Update status if provided + if newStatus != "" { + if err := iamValidateStatus(newStatus); err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + sa.Disabled = (newStatus == iamAccessKeyStatusInactive) + } + // Update description if provided (check for key existence to allow clearing) + if _, hasDescription := values["Description"]; hasDescription { + if len(newDescription) > MaxDescriptionLength { + return resp, &iamError{ + Code: iam.ErrCodeInvalidInputException, + Error: fmt.Errorf("description exceeds maximum length of %d characters", MaxDescriptionLength), + } + } + sa.Description = newDescription + } + // Update expiration if provided (check for key existence to allow clearing to 0) + if _, hasExpiration := values["Expiration"]; hasExpiration { + if newExpirationStr != "" { + newExpiration, err := strconv.ParseInt(newExpirationStr, 10, 64) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("invalid expiration format: %w", err)} + } + // Validate expiration value + if newExpiration < 0 { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("expiration must not be negative")} + } + if newExpiration > 0 && newExpiration < time.Now().Unix() { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("expiration must be in the future")} + } + // 0 is explicitly allowed to clear expiration + sa.Expiration = newExpiration + } else { + // Empty string means clear expiration (set to 0 = no expiration) + sa.Expiration = 0 + } + } + return resp, nil + } + } + + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("service account %s not found", saId)} +} + // handleImplicitUsername adds username who signs the request to values if 'username' is not specified. // According to AWS documentation: "If you do not specify a user name, IAM determines the user name // implicitly based on the Amazon Web Services access key ID signing the request." @@ -810,6 +1130,36 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { e.writeIamErrorResponse(w, r, iamErr) return } + // Service Account actions + case "CreateServiceAccount": + createdBy := s3_constants.GetIdentityNameFromContext(r) + response, iamErr = e.CreateServiceAccount(s3cfg, values, createdBy) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } + case "DeleteServiceAccount": + response, iamErr = e.DeleteServiceAccount(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } + case "ListServiceAccounts": + response = e.ListServiceAccounts(s3cfg, values) + changed = false + case "GetServiceAccount": + response, iamErr = e.GetServiceAccount(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } + changed = false + case "UpdateServiceAccount": + response, iamErr = e.UpdateServiceAccount(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } default: errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented) errorResponse := iamErrorResponse{} diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index 1ebb6043d..fa731f488 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -443,7 +443,7 @@ func TestEmbeddedIamDeleteUserPolicy(t *testing.T) { Name: "TestUser", Actions: []string{"Read", "Write", "List"}, Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST12345", SecretKey: "secret"}, + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, @@ -473,7 +473,7 @@ func TestEmbeddedIamDeleteUserPolicy(t *testing.T) { // Verify credentials are still intact assert.Len(t, api.mockConfig.Identities[0].Credentials, 1, "Credentials should NOT be deleted") - assert.Equal(t, "AKIATEST12345", api.mockConfig.Identities[0].Credentials[0].AccessKey) + assert.Equal(t, UserAccessKeyPrefix+"TEST12345", api.mockConfig.Identities[0].Credentials[0].AccessKey) // Verify actions/policy was cleared assert.Nil(t, api.mockConfig.Identities[0].Actions, "Actions should be cleared") @@ -579,7 +579,7 @@ func TestEmbeddedIamDeleteAccessKey(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST12345", SecretKey: "secret"}, + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, @@ -589,7 +589,7 @@ func TestEmbeddedIamDeleteAccessKey(t *testing.T) { form := url.Values{} form.Set("Action", "DeleteAccessKey") form.Set("UserName", "TestUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form @@ -618,7 +618,7 @@ func TestEmbeddedIamHandleImplicitUsername(t *testing.T) { { Name: "testuser1", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"}, + {AccessKey: UserAccessKeyPrefix + "TESTFAKEKEY000001", SecretKey: "testsecretfake"}, }, }, }, @@ -640,11 +640,11 @@ func TestEmbeddedIamHandleImplicitUsername(t *testing.T) { // No authorization header - should not set username {&http.Request{}, url.Values{}, ""}, // Valid auth header with known access key - should look up and find "testuser1" - {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"}, + {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"}, // Malformed auth header (no Credential=) - should not set username - {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""}, + {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =" + UserAccessKeyPrefix + "TESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""}, // Unknown access key - should not set username - {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""}, + {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + UserAccessKeyPrefix + "TESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""}, } for i, test := range tests { @@ -956,8 +956,8 @@ func TestEmbeddedIamListAccessKeysForUser(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST1", SecretKey: "secret1"}, - {AccessKey: "AKIATEST2", SecretKey: "secret2"}, + {AccessKey: UserAccessKeyPrefix + "TEST1", SecretKey: "secret1"}, + {AccessKey: UserAccessKeyPrefix + "TEST2", SecretKey: "secret2"}, }, }, }, @@ -1201,7 +1201,7 @@ func TestEmbeddedIamUpdateAccessKey(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST12345", SecretKey: "secret", Status: "Active"}, + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret", Status: "Active"}, }, }, }, @@ -1210,7 +1210,7 @@ func TestEmbeddedIamUpdateAccessKey(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) @@ -1235,7 +1235,7 @@ func TestEmbeddedIamUpdateAccessKey(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST12345", SecretKey: "secret", Status: "Inactive"}, + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret", Status: "Inactive"}, }, }, }, @@ -1244,7 +1244,7 @@ func TestEmbeddedIamUpdateAccessKey(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Active") req, _ := http.NewRequest("POST", "/", nil) @@ -1271,7 +1271,7 @@ func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIATEST12345", SecretKey: "secret"}, + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, @@ -1301,7 +1301,7 @@ func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "InvalidStatus") req, _ := http.NewRequest("POST", "/", nil) @@ -1320,7 +1320,7 @@ func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { t.Run("MissingUserName", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) @@ -1359,7 +1359,7 @@ func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "NonExistentUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) @@ -1379,7 +1379,7 @@ func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") - form.Set("AccessKeyId", "AKIATEST12345") + form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form @@ -1403,9 +1403,9 @@ func TestEmbeddedIamListAccessKeysShowsStatus(t *testing.T) { { Name: "TestUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"}, - {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"}, - {AccessKey: "AKIADEFAULT12", SecretKey: "secret3"}, // No status set, should default to Active + {AccessKey: UserAccessKeyPrefix + "ACTIVE123", SecretKey: "secret1", Status: "Active"}, + {AccessKey: UserAccessKeyPrefix + "INACTIVE1", SecretKey: "secret2", Status: "Inactive"}, + {AccessKey: UserAccessKeyPrefix + "DEFAULT12", SecretKey: "secret3"}, // No status set, should default to Active }, }, }, @@ -1428,9 +1428,9 @@ func TestEmbeddedIamListAccessKeysShowsStatus(t *testing.T) { statusMap[*meta.AccessKeyId] = *meta.Status } - assert.Equal(t, "Active", statusMap["AKIAACTIVE123"]) - assert.Equal(t, "Inactive", statusMap["AKIAINACTIVE1"]) - assert.Equal(t, "Active", statusMap["AKIADEFAULT12"]) // Default to Active + assert.Equal(t, "Active", statusMap[UserAccessKeyPrefix+"ACTIVE123"]) + assert.Equal(t, "Inactive", statusMap[UserAccessKeyPrefix+"INACTIVE1"]) + assert.Equal(t, "Active", statusMap[UserAccessKeyPrefix+"DEFAULT12"]) // Default to Active } // TestDisabledUserLookupFails tests that disabled users cannot authenticate @@ -1442,14 +1442,14 @@ func TestDisabledUserLookupFails(t *testing.T) { Name: "enabledUser", Disabled: false, Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIAENABLED123", SecretKey: "secret1"}, + {AccessKey: UserAccessKeyPrefix + "ENABLED123", SecretKey: "secret1"}, }, }, { Name: "disabledUser", Disabled: true, Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIADISABLED12", SecretKey: "secret2"}, + {AccessKey: UserAccessKeyPrefix + "DISABLED12", SecretKey: "secret2"}, }, }, }, @@ -1458,14 +1458,14 @@ func TestDisabledUserLookupFails(t *testing.T) { assert.NoError(t, err) // Enabled user should be found - identity, cred, found := iam.LookupByAccessKey("AKIAENABLED123") + identity, cred, found := iam.LookupByAccessKey(UserAccessKeyPrefix + "ENABLED123") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) assert.Equal(t, "enabledUser", identity.Name) // Disabled user should NOT be found - identity, cred, found = iam.LookupByAccessKey("AKIADISABLED12") + identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "DISABLED12") assert.False(t, found) assert.Nil(t, identity) assert.Nil(t, cred) @@ -1479,9 +1479,9 @@ func TestInactiveAccessKeyLookupFails(t *testing.T) { { Name: "testUser", Credentials: []*iam_pb.Credential{ - {AccessKey: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"}, - {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"}, - {AccessKey: "AKIADEFAULT12", SecretKey: "secret3"}, // No status = Active + {AccessKey: UserAccessKeyPrefix + "ACTIVE123", SecretKey: "secret1", Status: "Active"}, + {AccessKey: UserAccessKeyPrefix + "INACTIVE1", SecretKey: "secret2", Status: "Inactive"}, + {AccessKey: UserAccessKeyPrefix + "DEFAULT12", SecretKey: "secret3"}, // No status = Active }, }, }, @@ -1490,19 +1490,19 @@ func TestInactiveAccessKeyLookupFails(t *testing.T) { assert.NoError(t, err) // Active key should be found - identity, cred, found := iam.LookupByAccessKey("AKIAACTIVE123") + identity, cred, found := iam.LookupByAccessKey(UserAccessKeyPrefix + "ACTIVE123") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) // Inactive key should NOT be found - identity, cred, found = iam.LookupByAccessKey("AKIAINACTIVE1") + identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "INACTIVE1") assert.False(t, found) assert.Nil(t, identity) assert.Nil(t, cred) // Key with no status (default Active) should be found - identity, cred, found = iam.LookupByAccessKey("AKIADEFAULT12") + identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "DEFAULT12") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) @@ -1655,10 +1655,9 @@ func TestOldCodeOrderWouldFail(t *testing.T) { // With old code order, this would fail with SignatureDoesNotMatch // because the body is empty when signature verification tries to hash it - assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, + assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, "Expected SignatureDoesNotMatch when ParseForm is called before auth") assert.Nil(t, identity) t.Log("This demonstrates the bug: ParseForm before auth causes SignatureDoesNotMatch") } - diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 7a8062a7a..c811d668b 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -72,6 +72,7 @@ type S3ApiServer struct { inFlightUploads int64 inFlightDataLimitCond *sync.Cond embeddedIam *EmbeddedIamApi // Embedded IAM API server (when enabled) + stsHandlers *STSHandlers // STS HTTP handlers for AssumeRoleWithWebIdentity cipher bool // encrypt data on volume servers } @@ -187,6 +188,12 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl // Set the integration in the traditional IAM for compatibility iam.SetIAMIntegration(s3iam) + // Initialize STS HTTP handlers for AssumeRoleWithWebIdentity endpoint + if stsService := iamManager.GetSTSService(); stsService != nil { + s3ApiServer.stsHandlers = NewSTSHandlers(stsService) + glog.V(1).Infof("STS HTTP handlers initialized for AssumeRoleWithWebIdentity") + } + glog.V(1).Infof("Advanced IAM system initialized successfully with HA filer support") } } @@ -609,7 +616,18 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { } }) - // Embedded IAM API (POST to "/" with Action parameter) + // STS API endpoint for AssumeRoleWithWebIdentity + // POST /?Action=AssumeRoleWithWebIdentity&WebIdentityToken=... + // This endpoint is unauthenticated - the JWT token in the request is the authentication + // IMPORTANT: Register this BEFORE the general IAM route to prevent interception + if s3a.stsHandlers != nil { + apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithWebIdentity"). + HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS")) + glog.V(0).Infof("STS API enabled on S3 port (AssumeRoleWithWebIdentity)") + } + + // Embedded IAM API endpoint + // POST / (without specific query parameters) // This must be before ListBuckets since IAM uses POST and ListBuckets uses GET // Uses AuthIam for granular permission checking: // - Self-service operations (own access keys) don't require admin diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go new file mode 100644 index 000000000..914f962ff --- /dev/null +++ b/weed/s3api/s3api_sts.go @@ -0,0 +1,282 @@ +package s3api + +// This file provides STS (Security Token Service) HTTP endpoints for AWS SDK compatibility. +// It exposes AssumeRoleWithWebIdentity as an HTTP endpoint that can be used with +// AWS SDKs to obtain temporary credentials using OIDC/JWT tokens. + +import ( + "encoding/xml" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// STS API constants matching AWS STS specification +const ( + stsAPIVersion = "2011-06-15" + stsAction = "Action" + stsVersion = "Version" + stsWebIdentityToken = "WebIdentityToken" + stsRoleArn = "RoleArn" + stsRoleSessionName = "RoleSessionName" + stsDurationSeconds = "DurationSeconds" + + // STS Action names + actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity" +) + +// STSHandlers provides HTTP handlers for STS operations +type STSHandlers struct { + stsService *sts.STSService +} + +// NewSTSHandlers creates a new STSHandlers instance +func NewSTSHandlers(stsService *sts.STSService) *STSHandlers { + return &STSHandlers{ + stsService: stsService, + } +} + +// HandleSTSRequest is the main entry point for STS requests +// It routes requests based on the Action parameter +func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, err) + return + } + + // Validate API version + version := r.Form.Get(stsVersion) + if version != "" && version != stsAPIVersion { + h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, + fmt.Errorf("invalid STS API version %s, expecting %s", version, stsAPIVersion)) + return + } + + // Route based on action + action := r.Form.Get(stsAction) + switch action { + case actionAssumeRoleWithWebIdentity: + h.handleAssumeRoleWithWebIdentity(w, r) + default: + h.writeSTSErrorResponse(w, r, STSErrInvalidAction, + fmt.Errorf("unsupported action: %s", action)) + } +} + +// handleAssumeRoleWithWebIdentity handles the AssumeRoleWithWebIdentity API action +func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Extract parameters from form (supports both query and POST body) + roleArn := r.FormValue("RoleArn") + webIdentityToken := r.FormValue("WebIdentityToken") + roleSessionName := r.FormValue("RoleSessionName") + + // Validate required parameters + if webIdentityToken == "" { + h.writeSTSErrorResponse(w, r, STSErrMissingParameter, + fmt.Errorf("WebIdentityToken is required")) + return + } + + if roleArn == "" { + h.writeSTSErrorResponse(w, r, STSErrMissingParameter, + fmt.Errorf("RoleArn is required")) + return + } + + if roleSessionName == "" { + h.writeSTSErrorResponse(w, r, STSErrMissingParameter, + fmt.Errorf("RoleSessionName is required")) + return + } + + // Parse and validate DurationSeconds + var durationSeconds *int64 + if dsStr := r.FormValue("DurationSeconds"); dsStr != "" { + ds, err := strconv.ParseInt(dsStr, 10, 64) + if err != nil { + h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, + fmt.Errorf("invalid DurationSeconds: %w", err)) + return + } + + // Enforce AWS STS-compatible duration range for AssumeRoleWithWebIdentity + // AWS allows 900 seconds (15 minutes) to 43200 seconds (12 hours) + const ( + minDurationSeconds = int64(900) + maxDurationSeconds = int64(43200) + ) + if ds < minDurationSeconds || ds > maxDurationSeconds { + h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, + fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)) + return + } + + durationSeconds = &ds + } + + // Check if STS service is initialized + if h.stsService == nil || !h.stsService.IsInitialized() { + h.writeSTSErrorResponse(w, r, STSErrSTSNotReady, + fmt.Errorf("STS service not initialized")) + return + } + + // Build request for STS service + request := &sts.AssumeRoleWithWebIdentityRequest{ + RoleArn: roleArn, + WebIdentityToken: webIdentityToken, + RoleSessionName: roleSessionName, + DurationSeconds: durationSeconds, + } + + // Call STS service + response, err := h.stsService.AssumeRoleWithWebIdentity(ctx, request) + if err != nil { + glog.V(2).Infof("AssumeRoleWithWebIdentity failed: %v", err) + + // Use typed errors for robust error checking + // This decouples HTTP layer from service implementation details + errCode := STSErrAccessDenied + if errors.Is(err, sts.ErrTypedTokenExpired) { + errCode = STSErrExpiredToken + } else if errors.Is(err, sts.ErrTypedInvalidToken) { + errCode = STSErrInvalidParameterValue + } else if errors.Is(err, sts.ErrTypedInvalidIssuer) { + errCode = STSErrInvalidParameterValue + } else if errors.Is(err, sts.ErrTypedInvalidAudience) { + errCode = STSErrInvalidParameterValue + } else if errors.Is(err, sts.ErrTypedMissingClaims) { + errCode = STSErrInvalidParameterValue + } + + h.writeSTSErrorResponse(w, r, errCode, err) + return + } + + // Build and return XML response + xmlResponse := &AssumeRoleWithWebIdentityResponse{ + Result: WebIdentityResult{ + Credentials: STSCredentials{ + AccessKeyId: response.Credentials.AccessKeyId, + SecretAccessKey: response.Credentials.SecretAccessKey, + SessionToken: response.Credentials.SessionToken, + Expiration: response.Credentials.Expiration.Format(time.RFC3339), + }, + SubjectFromWebIdentityToken: response.AssumedRoleUser.Subject, + }, + } + xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano()) + + s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse) +} + +// STS Response types for XML marshaling + +// AssumeRoleWithWebIdentityResponse is the response for AssumeRoleWithWebIdentity +type AssumeRoleWithWebIdentityResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithWebIdentityResponse"` + Result WebIdentityResult `xml:"AssumeRoleWithWebIdentityResult"` + ResponseMetadata struct { + RequestId string `xml:"RequestId,omitempty"` + } `xml:"ResponseMetadata,omitempty"` +} + +// WebIdentityResult contains the result of AssumeRoleWithWebIdentity +type WebIdentityResult struct { + Credentials STSCredentials `xml:"Credentials"` + SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"` + AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"` +} + +// STSCredentials represents temporary security credentials +type STSCredentials struct { + AccessKeyId string `xml:"AccessKeyId"` + SecretAccessKey string `xml:"SecretAccessKey"` + SessionToken string `xml:"SessionToken"` + Expiration string `xml:"Expiration"` +} + +// AssumedRoleUser contains information about the assumed role +type AssumedRoleUser struct { + AssumedRoleId string `xml:"AssumedRoleId"` + Arn string `xml:"Arn"` +} + +// STS Error types + +// STSErrorCode represents STS error codes +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" +) + +// stsErrorResponses maps error codes to HTTP status and messages +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"}, +} + +// STSErrorResponse is the XML error response format +type STSErrorResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ ErrorResponse"` + Error struct { + Type string `xml:"Type"` + Code string `xml:"Code"` + Message string `xml:"Message"` + } `xml:"Error"` + RequestId string `xml:"RequestId"` +} + +// writeSTSErrorResponse writes an STS error response +func (h *STSHandlers) writeSTSErrorResponse(w http.ResponseWriter, r *http.Request, code STSErrorCode, err error) { + errInfo, ok := stsErrorResponses[code] + if !ok { + errInfo = stsErrorResponses[STSErrInternalError] + } + + message := errInfo.Message + if err != nil { + message = err.Error() + } + + response := STSErrorResponse{ + RequestId: fmt.Sprintf("%d", time.Now().UnixNano()), + } + + // Server-side errors use "Receiver" type per AWS spec + if code == STSErrInternalError || code == STSErrSTSNotReady { + response.Error.Type = "Receiver" + } else { + response.Error.Type = "Sender" + } + + response.Error.Code = string(code) + response.Error.Message = message + + glog.V(1).Infof("STS error response: code=%s, type=%s, message=%s", code, response.Error.Type, message) + s3err.WriteXMLResponse(w, r, errInfo.HTTPStatusCode, response) +}