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) +}