Browse Source
IAM: Add Service Account Support (#7744) (#7901)
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.fix-bucket-name-case-7910
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2155 additions and 110 deletions
-
431test/s3/iam/s3_service_account_security_test.go
-
363test/s3/iam/s3_service_account_test.go
-
260test/s3/iam/s3_sts_test.go
-
15weed/iam/oidc/oidc_provider.go
-
22weed/iam/providers/errors.go
-
52weed/iam/responses.go
-
23weed/iam/sts/constants.go
-
15weed/iam/sts/sts_service.go
-
34weed/iamapi/iamapi_response.go
-
16weed/pb/iam.proto
-
202weed/pb/iam_pb/iam.pb.go
-
2weed/pb/iam_pb/iam_grpc.pb.go
-
62weed/s3api/auth_credentials.go
-
21weed/s3api/auth_signature_v2.go
-
14weed/s3api/auth_signature_v4.go
-
2weed/s3api/chunked_reader_v4_test.go
-
356weed/s3api/s3api_embedded_iam.go
-
73weed/s3api/s3api_embedded_iam_test.go
-
20weed/s3api/s3api_server.go
-
282weed/s3api/s3api_sts.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)") |
|||
}) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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") |
|||
) |
|||
@ -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) |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue