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