feat: add AWS-compatible standalone IAM service

Closes #1640

Add a standalone AWS IAM Query API implementation for managing IAM users through standard AWS SDKs and the AWS CLI.

Server usage

Start the IAM server with internal file-backed storage:

    mkdir -p /tmp/versitygw-iam
    ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --dir /tmp/versitygw-iam

Start the IAM server with Vault KV v2 storage using AppRole:

    VGW_IAM_VAULT_ROLE_SECRET=<role-secret> ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --vault-endpoint-url http://127.0.0.1:8200 --vault-auth-method approle --vault-role-id <role-id> --vault-mount-path kv --vault-secret-storage-path iam

Vault authentication also supports root tokens, separate authentication and secret-storage namespaces, custom mount paths, server certificate validation, and mutual TLS client certificates.

Configure the AWS CLI credentials used by the IAM server:

    export AWS_ACCESS_KEY_ID=user
    export AWS_SECRET_ACCESS_KEY=pass
    export AWS_DEFAULT_REGION=us-east-1

Implemented IAM actions

CreateUser creates an IAM user with an AWS-compatible ARN, generated AIDA user ID, creation timestamp, optional path, and tags. It validates usernames, paths, tag limits, reserved tag prefixes, duplicate tag keys, and existing users.

    aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob

    aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob --path /engineering/ --tags Key=team,Value=storage

GetUser returns a stored user or the root identity when requested without a username through the IAM Query API.

    aws --endpoint-url http://127.0.0.1:7070 iam get-user --user-name bob

ListUsers returns users in deterministic username order and supports path filtering, marker-based pagination, and MaxItems limits.

    aws --endpoint-url http://127.0.0.1:7070 iam list-users

    aws --endpoint-url http://127.0.0.1:7070 iam list-users --path-prefix /engineering/ --max-items 100

UpdateUser updates the username and/or path, recalculates the user ARN, and rejects conflicts with existing users.

    aws --endpoint-url http://127.0.0.1:7070 iam update-user --user-name bob --new-user-name robert --new-path /platform/

DeleteUser permanently removes an IAM user and returns AWS-compatible errors for missing users.

    aws --endpoint-url http://127.0.0.1:7070 iam delete-user --user-name robert

IAM protocol and authentication

- Support the AWS IAM Query protocol version 2010-05-08 over GET and POST form requests.
- Return AWS-compatible XML responses, error documents, status codes, request IDs, user metadata, and pagination fields.
- Authenticate root credentials with AWS Signature Version 4 for the IAM service in us-east-1.
- Support both Authorization-header and query-string SigV4 authentication.
- Validate credential scope, signed headers, timestamps, clock skew, content length, signatures, and unsupported signature or session-token modes.
- Add IAM-specific validation and error mapping for malformed requests, invalid actions, duplicate entities, missing users, throttling, and internal failures.

Storage implementations

- Add an internal JSON-backed store using iam.json and iam.json.backup with atomic temporary-file replacement, concurrent access protection, stable ordering, pagination, and persistence across restarts.
- Add a Vault KV v2 store with one secret per user, CAS-based duplicate protection, permanent deletion, AppRole reauthentication, namespace support, configurable authentication and KV mounts, root-token authentication, and TLS/mTLS configuration.
- Introduce a common Storer interface and require exactly one storage backend to be configured.

Server and embedding support

- Register the new `versitygw iam` command with environment-variable and CLI configuration for both storage backends.
- Add `embedgw.RunIAMAPI` and `IAMConfig` for embedding the IAM service in Go applications.

Gateway-level internal packages

- Add `internal/iamstore` as a reusable generic file-backed IAM persistence engine and migrate the existing gateway internal IAM service to it.
- Add `internal/sigv4auth` for shared SigV4 header and presigned-query parsing, canonical request generation, signature verification, and structured authentication errors.
- Refactor the S3 authentication paths to use the shared SigV4 implementation while preserving S3-specific error responses.
- Add `internal/httpctx` for shared Fiber context keys and AWS-style request ID handling.
- Add `internal/routekit` for shared query, form, and header route matchers.
- Add `internal/netutil` for reusable certificate storage, hostname-aware listeners, multi-address serving, TLS listeners, and UNIX socket handling.
- Update the custom SigV4 signer to honor an explicitly supplied signed-header list so unrelated headers do not alter IAM signatures.

Testing and CI

- Add AWS IAM SDK-based integration coverage for all supported user actions, header authentication, query authentication, validation, errors, filtering, and pagination.
- Split standalone IAM tests into `versitygw test iam` and retain existing gateway IAM tests under `versitygw test gw-iam`.
- Add unit coverage for controllers, authentication, routing, storage, embedding, listeners, request matching, persistence, and signing behavior.
- Add `runiamtests.sh` to exercise internal storage over HTTP and HTTPS plus Vault storage through AppRole.
- Add a dedicated IAM functional-test workflow with a Vault service and merged runtime coverage reporting.
- Include the IAM test runner in shellcheck and add the AWS IAM SDK dependency.
This commit is contained in:
niksis02
2026-07-02 23:02:02 +04:00
parent 3e848c8177
commit b590ac9eca
59 changed files with 9116 additions and 630 deletions
+189 -2
View File
@@ -940,7 +940,7 @@ func TestFullFlow(ts *TestState) {
if ts.conf.versioningEnabled {
TestVersioning(ts)
}
TestIAM(ts)
TestGatewayIAM(ts)
TestServer(ts)
}
@@ -1063,7 +1063,7 @@ func TestScoutfs(ts *TestState) {
ts.Run(DeleteObject_directory_not_empty)
}
func TestIAM(ts *TestState) {
func TestGatewayIAM(ts *TestState) {
ts.Run(IAM_user_access_denied)
ts.Run(IAM_userplus_access_denied)
ts.Run(IAM_userplus_CreateBucket)
@@ -1075,6 +1075,115 @@ func TestIAM(ts *TestState) {
ts.Run(IAM_CreateBucket_success)
}
func TestIAMAuth(ts *TestState) {
ts.Run(IAMAuth_invalid_auth_header)
ts.Run(IAMAuth_unsupported_signature_version)
ts.Run(IAMAuth_malformed_component)
ts.Run(IAMAuth_missing_authorization_component)
ts.Run(IAMAuth_malformed_credential)
ts.Run(IAMAuth_credentials_invalid_terminal)
ts.Run(IAMAuth_credentials_incorrect_service)
ts.Run(IAMAuth_credentials_incorrect_region)
ts.Run(IAMAuth_credentials_invalid_date)
ts.Run(IAMAuth_credentials_future_date)
ts.Run(IAMAuth_credentials_past_date)
ts.Run(IAMAuth_credentials_non_existing_access_key)
ts.Run(IAMAuth_missing_date_header)
ts.Run(IAMAuth_invalid_date_header)
ts.Run(IAMAuth_date_mismatch)
ts.Run(IAMAuth_invalid_sha256_payload_hash_ignored)
ts.Run(IAMAuth_unsigned_required_header)
ts.Run(IAMAuth_unsigned_non_required_header)
ts.Run(IAMAuth_signature_error_incorrect_secret_key)
ts.Run(IAMAuth_sigv2_not_supported)
ts.Run(IAMAuth_with_expect_header)
}
func TestIAMQueryAuth(ts *TestState) {
ts.Run(IAMQueryAuth_success)
ts.Run(IAMQueryAuth_security_token_not_supported)
ts.Run(IAMQueryAuth_unsupported_algorithm)
ts.Run(IAMQueryAuth_ECDSA_not_supported)
ts.Run(IAMQueryAuth_missing_query_parameters)
ts.Run(IAMQueryAuth_malformed_credential)
ts.Run(IAMQueryAuth_credentials_invalid_terminal)
ts.Run(IAMQueryAuth_credentials_incorrect_service)
ts.Run(IAMQueryAuth_credentials_incorrect_region)
ts.Run(IAMQueryAuth_credentials_invalid_date)
ts.Run(IAMQueryAuth_non_existing_access_key)
ts.Run(IAMQueryAuth_invalid_date)
ts.Run(IAMQueryAuth_date_mismatch)
ts.Run(IAMQueryAuth_unsigned_query_parameter)
ts.Run(IAMQueryAuth_incorrect_secret_key)
ts.Run(IAMQueryAuth_invalid_sha256_payload_hash_ignored)
ts.Run(IAMQueryAuth_with_expect_header)
}
func TestIAMCreateUser(ts *TestState) {
ts.Run(IAMCreateUser_user_already_exists)
ts.Run(IAMCreateUser_invalid_user_name)
ts.Run(IAMCreateUser_long_user_name)
ts.Run(IAMCreateUser_missing_user_name)
ts.Run(IAMCreateUser_invalid_tag_key)
ts.Run(IAMCreateUser_invalid_tag_value)
ts.Run(IAMCreateUser_long_tag_key)
ts.Run(IAMCreateUser_long_tag_value)
ts.Run(IAMCreateUser_duplicate_tag_keys)
ts.Run(IAMCreateUser_success)
ts.Run(IAMCreateUser_default_path)
ts.Run(IAMCreateUser_invalid_path)
ts.Run(IAMCreateUser_long_path)
}
func TestIAMGetUser(ts *TestState) {
ts.Run(IAMGetUser_long_user_name)
ts.Run(IAMGetUser_invalid_user_name)
ts.Run(IAMGetUser_non_existing_user)
ts.Run(IAMGetUser_success)
ts.Run(IAMGetUser_root_user)
}
func TestIAMListUsers(ts *TestState) {
ts.Run(IAMListUsers_invalid_path_prefix)
ts.Run(IAMListUsers_long_path_prefix)
ts.Run(IAMListUsers_invalid_max_items)
ts.Run(IAMListUsers_invalid_max_items_format)
ts.Run(IAMListUsers_empty_result)
ts.Run(IAMListUsers_success)
ts.Run(IAMListUsers_path_prefix)
ts.Run(IAMListUsers_pagination)
ts.Run(IAMListUsers_path_prefix_pagination)
}
func TestIAMDeleteUser(ts *TestState) {
ts.Run(IAMDeleteUser_invalid_user_name)
ts.Run(IAMDeleteUser_long_user_name)
ts.Run(IAMDeleteUser_non_existing_user)
ts.Run(IAMDeleteUser_success)
}
func TestIAMUpdateUser(ts *TestState) {
ts.Run(IAMUpdateUser_invalid_user_name)
ts.Run(IAMUpdateUser_long_user_name)
ts.Run(IAMUpdateUser_invalid_new_user_name)
ts.Run(IAMUpdateUser_long_new_user_name)
ts.Run(IAMUpdateUser_non_existing_user)
ts.Run(IAMUpdateUser_invalid_new_path)
ts.Run(IAMUpdateUser_long_new_path)
ts.Run(IAMUpdateUser_new_user_name_already_exists)
ts.Run(IAMUpdateUser_success)
}
func TestIAM(ts *TestState) {
TestIAMAuth(ts)
TestIAMQueryAuth(ts)
TestIAMCreateUser(ts)
TestIAMGetUser(ts)
TestIAMListUsers(ts)
TestIAMDeleteUser(ts)
TestIAMUpdateUser(ts)
}
func TestAccessControl(ts *TestState) {
ts.Run(AccessControl_default_ACL_user_access_denied)
ts.Run(AccessControl_default_ACL_userplus_access_denied)
@@ -1379,6 +1488,84 @@ func GetIntTests() IntTests {
"Authentication_signature_error_incorrect_secret_key": Authentication_signature_error_incorrect_secret_key,
"Authentication_sigv2_not_supported": Authentication_sigv2_not_supported,
"Authentication_with_expect_header": Authentication_with_expect_header,
"IAMAuth_invalid_auth_header": IAMAuth_invalid_auth_header,
"IAMAuth_unsupported_signature_version": IAMAuth_unsupported_signature_version,
"IAMAuth_malformed_component": IAMAuth_malformed_component,
"IAMAuth_missing_authorization_component": IAMAuth_missing_authorization_component,
"IAMAuth_malformed_credential": IAMAuth_malformed_credential,
"IAMAuth_credentials_invalid_terminal": IAMAuth_credentials_invalid_terminal,
"IAMAuth_credentials_incorrect_service": IAMAuth_credentials_incorrect_service,
"IAMAuth_credentials_incorrect_region": IAMAuth_credentials_incorrect_region,
"IAMAuth_credentials_invalid_date": IAMAuth_credentials_invalid_date,
"IAMAuth_credentials_future_date": IAMAuth_credentials_future_date,
"IAMAuth_credentials_past_date": IAMAuth_credentials_past_date,
"IAMAuth_credentials_non_existing_access_key": IAMAuth_credentials_non_existing_access_key,
"IAMAuth_missing_date_header": IAMAuth_missing_date_header,
"IAMAuth_invalid_date_header": IAMAuth_invalid_date_header,
"IAMAuth_date_mismatch": IAMAuth_date_mismatch,
"IAMAuth_invalid_sha256_payload_hash_ignored": IAMAuth_invalid_sha256_payload_hash_ignored,
"IAMAuth_unsigned_required_header": IAMAuth_unsigned_required_header,
"IAMAuth_unsigned_non_required_header": IAMAuth_unsigned_non_required_header,
"IAMAuth_signature_error_incorrect_secret_key": IAMAuth_signature_error_incorrect_secret_key,
"IAMAuth_sigv2_not_supported": IAMAuth_sigv2_not_supported,
"IAMAuth_with_expect_header": IAMAuth_with_expect_header,
"IAMQueryAuth_success": IAMQueryAuth_success,
"IAMQueryAuth_security_token_not_supported": IAMQueryAuth_security_token_not_supported,
"IAMQueryAuth_unsupported_algorithm": IAMQueryAuth_unsupported_algorithm,
"IAMQueryAuth_ECDSA_not_supported": IAMQueryAuth_ECDSA_not_supported,
"IAMQueryAuth_missing_query_parameters": IAMQueryAuth_missing_query_parameters,
"IAMQueryAuth_malformed_credential": IAMQueryAuth_malformed_credential,
"IAMQueryAuth_credentials_invalid_terminal": IAMQueryAuth_credentials_invalid_terminal,
"IAMQueryAuth_credentials_incorrect_service": IAMQueryAuth_credentials_incorrect_service,
"IAMQueryAuth_credentials_incorrect_region": IAMQueryAuth_credentials_incorrect_region,
"IAMQueryAuth_credentials_invalid_date": IAMQueryAuth_credentials_invalid_date,
"IAMQueryAuth_non_existing_access_key": IAMQueryAuth_non_existing_access_key,
"IAMQueryAuth_invalid_date": IAMQueryAuth_invalid_date,
"IAMQueryAuth_date_mismatch": IAMQueryAuth_date_mismatch,
"IAMQueryAuth_unsigned_query_parameter": IAMQueryAuth_unsigned_query_parameter,
"IAMQueryAuth_incorrect_secret_key": IAMQueryAuth_incorrect_secret_key,
"IAMQueryAuth_invalid_sha256_payload_hash_ignored": IAMQueryAuth_invalid_sha256_payload_hash_ignored,
"IAMQueryAuth_with_expect_header": IAMQueryAuth_with_expect_header,
"IAMCreateUser_user_already_exists": IAMCreateUser_user_already_exists,
"IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name,
"IAMCreateUser_long_user_name": IAMCreateUser_long_user_name,
"IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name,
"IAMCreateUser_invalid_tag_key": IAMCreateUser_invalid_tag_key,
"IAMCreateUser_invalid_tag_value": IAMCreateUser_invalid_tag_value,
"IAMCreateUser_long_tag_key": IAMCreateUser_long_tag_key,
"IAMCreateUser_long_tag_value": IAMCreateUser_long_tag_value,
"IAMCreateUser_duplicate_tag_keys": IAMCreateUser_duplicate_tag_keys,
"IAMCreateUser_success": IAMCreateUser_success,
"IAMCreateUser_default_path": IAMCreateUser_default_path,
"IAMCreateUser_invalid_path": IAMCreateUser_invalid_path,
"IAMCreateUser_long_path": IAMCreateUser_long_path,
"IAMGetUser_long_user_name": IAMGetUser_long_user_name,
"IAMGetUser_invalid_user_name": IAMGetUser_invalid_user_name,
"IAMGetUser_non_existing_user": IAMGetUser_non_existing_user,
"IAMGetUser_success": IAMGetUser_success,
"IAMGetUser_root_user": IAMGetUser_root_user,
"IAMListUsers_invalid_path_prefix": IAMListUsers_invalid_path_prefix,
"IAMListUsers_long_path_prefix": IAMListUsers_long_path_prefix,
"IAMListUsers_invalid_max_items": IAMListUsers_invalid_max_items,
"IAMListUsers_invalid_max_items_format": IAMListUsers_invalid_max_items_format,
"IAMListUsers_empty_result": IAMListUsers_empty_result,
"IAMListUsers_success": IAMListUsers_success,
"IAMListUsers_path_prefix": IAMListUsers_path_prefix,
"IAMListUsers_pagination": IAMListUsers_pagination,
"IAMListUsers_path_prefix_pagination": IAMListUsers_path_prefix_pagination,
"IAMDeleteUser_invalid_user_name": IAMDeleteUser_invalid_user_name,
"IAMDeleteUser_long_user_name": IAMDeleteUser_long_user_name,
"IAMDeleteUser_non_existing_user": IAMDeleteUser_non_existing_user,
"IAMDeleteUser_success": IAMDeleteUser_success,
"IAMUpdateUser_invalid_user_name": IAMUpdateUser_invalid_user_name,
"IAMUpdateUser_long_user_name": IAMUpdateUser_long_user_name,
"IAMUpdateUser_invalid_new_user_name": IAMUpdateUser_invalid_new_user_name,
"IAMUpdateUser_long_new_user_name": IAMUpdateUser_long_new_user_name,
"IAMUpdateUser_non_existing_user": IAMUpdateUser_non_existing_user,
"IAMUpdateUser_invalid_new_path": IAMUpdateUser_invalid_new_path,
"IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path,
"IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists,
"IAMUpdateUser_success": IAMUpdateUser_success,
"PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported,
"PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm,
"PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported,
+365
View File
@@ -0,0 +1,365 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/versity/versitygw/iamapi/iamerr"
)
const (
iamAuthPath = "?Action=ListUsers&Version=2010-05-08"
iamAuthRegion = "us-east-1"
)
func IAMAuth_invalid_auth_header(s *S3Conf) error {
testName := "IAMAuth_invalid_auth_header"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("Authorization", "invalid_header")
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken))
})
}
func IAMAuth_unsupported_signature_version(s *S3Conf) error {
testName := "IAMAuth_unsupported_signature_version"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
authHdr = strings.Replace(authHdr, "AWS4-HMAC-SHA256", "AWS2-HMAC-SHA1", 1)
req.Header.Set("Authorization", authHdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken))
})
}
func IAMAuth_malformed_component(s *S3Conf) error {
testName := "IAMAuth_malformed_component"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/iam/aws4_request,SignedHeaders-Content-Length,Signature=signature")
return checkIAMAuthRequest(s, req, iamerr.IncompleteSignatureMalformedComponent("SignedHeaders-Content-Length"))
})
}
func IAMAuth_missing_authorization_component(s *S3Conf) error {
testName := "IAMAuth_missing_authorization_component"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
testCases := []struct {
name string
component string
authorization string
}{
{
name: "missing_credentials",
component: "Credential",
authorization: "AWS4-HMAC-SHA256 missing_creds=access/20250912/us-east-1/iam/aws4_request,SignedHeaders=content-length;x-amz-date,Signature=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29",
},
{
name: "missing_signedheaders",
component: "SignedHeaders",
authorization: "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/iam/aws4_request,missing=content-length;x-amz-date,Signature=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29",
},
{
name: "missing_signature",
component: "Signature",
authorization: "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/iam/aws4_request,SignedHeaders=content-length;x-amz-date,missing=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29",
},
}
for _, testCase := range testCases {
testReq := req.Clone(req.Context())
testReq.Header.Set("Authorization", testCase.authorization)
err := checkIAMAuthRequest(s, testReq, iamerr.IncompleteSignatureMissingAuthorizationComponent(testCase.component, testCase.authorization))
if err != nil {
return fmt.Errorf("%s: %w", testCase.name, err)
}
}
return nil
})
}
func IAMAuth_malformed_credential(s *S3Conf) error {
testName := "IAMAuth_malformed_credential"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=[^,]+,")
hdr := regExp.ReplaceAllString(authHdr, "Credential=access/20260627/us-east-1/iam/extra/things,")
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.IncompleteSignatureMalformedCredential("access/20260627/us-east-1/iam/extra/things"))
})
}
func IAMAuth_credentials_invalid_terminal(s *S3Conf) error {
testName := "IAMAuth_credentials_invalid_terminal"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=[^,]+,")
hdr := regExp.ReplaceAllString(authHdr, "Credential=access/20260627/us-east-1/iam/aws_request,")
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidTerminal))
})
}
func IAMAuth_credentials_incorrect_service(s *S3Conf) error {
testName := "IAMAuth_credentials_incorrect_service"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=[^,]+,")
hdr := regExp.ReplaceAllString(authHdr, "Credential=access/20260627/us-east-1/ec2/aws4_request,")
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrIncorrectService))
})
}
func IAMAuth_credentials_incorrect_region(s *S3Conf) error {
testName := "IAMAuth_credentials_incorrect_region"
cfg := iamAuthConfig(testName)
cfg.region = "us-west-1"
return authHandler(s, cfg, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidRegion))
})
}
func IAMAuth_credentials_invalid_date(s *S3Conf) error {
testName := "IAMAuth_credentials_invalid_date"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=[^,]+,")
hdr := regExp.ReplaceAllString(authHdr, "Credential=access/3223423234/us-east-1/iam/aws4_request,")
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate))
})
}
func IAMAuth_credentials_future_date(s *S3Conf) error {
testName := "IAMAuth_credentials_future_date"
cfg := iamAuthConfig(testName)
cfg.date = time.Now().UTC().Add(5 * 24 * time.Hour)
return authHandler(s, cfg, func(req *http.Request) error {
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var received IAMErrorResponse
if err := xml.Unmarshal(body, &received); err != nil {
return err
}
if resp.StatusCode != http.StatusForbidden {
return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusForbidden, resp.StatusCode)
}
if received.Error.Type != string(iamerr.TypeSender) {
return fmt.Errorf("expected IAM error type to be %q, instead got %q", iamerr.TypeSender, received.Error.Type)
}
if received.Error.Code != "SignatureDoesNotMatch" {
return fmt.Errorf("expected IAM error code to be %q, instead got %q", "SignatureDoesNotMatch", received.Error.Code)
}
messagePattern := `^Signature not yet current: [0-9]{8}T[0-9]{6}Z is still later than [0-9]{8}T[0-9]{6}Z \([0-9]{8}T[0-9]{6}Z \+ 15 min\.\)$`
if !regexp.MustCompile(messagePattern).MatchString(received.Error.Message) {
return fmt.Errorf("IAM error message %q does not match %q", received.Error.Message, messagePattern)
}
return nil
})
}
func IAMAuth_credentials_past_date(s *S3Conf) error {
testName := "IAMAuth_credentials_past_date"
cfg := iamAuthConfig(testName)
cfg.date = time.Now().UTC().Add(-5 * 24 * time.Hour)
return authHandler(s, cfg, func(req *http.Request) error {
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var received IAMErrorResponse
if err := xml.Unmarshal(body, &received); err != nil {
return err
}
if resp.StatusCode != http.StatusForbidden {
return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusForbidden, resp.StatusCode)
}
if received.Error.Type != string(iamerr.TypeSender) {
return fmt.Errorf("expected IAM error type to be %q, instead got %q", iamerr.TypeSender, received.Error.Type)
}
if received.Error.Code != "SignatureDoesNotMatch" {
return fmt.Errorf("expected IAM error code to be %q, instead got %q", "SignatureDoesNotMatch", received.Error.Code)
}
messagePattern := `^Signature expired: [0-9]{8}T[0-9]{6}Z is now earlier than [0-9]{8}T[0-9]{6}Z \([0-9]{8}T[0-9]{6}Z - 15 min\.\)$`
if !regexp.MustCompile(messagePattern).MatchString(received.Error.Message) {
return fmt.Errorf("IAM error message %q does not match %q", received.Error.Message, messagePattern)
}
return nil
})
}
func IAMAuth_credentials_non_existing_access_key(s *S3Conf) error {
testName := "IAMAuth_credentials_non_existing_access_key"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
accessKeyID := "a_rarely_existing_access_key_id_a7s86df78as6df89790a8sd7f"
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=([^/]+)")
hdr := regExp.ReplaceAllString(authHdr, "Credential="+accessKeyID)
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID))
})
}
func IAMAuth_missing_date_header(s *S3Conf) error {
testName := "IAMAuth_missing_date_header"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("X-Amz-Date", "")
return checkIAMAuthRequest(s, req, iamerr.IncompleteSignatureMissingDate(req.Header.Get("Authorization")))
})
}
func IAMAuth_invalid_date_header(s *S3Conf) error {
testName := "IAMAuth_invalid_date_header"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
const invalidDate = "03032006"
req.Header.Set("X-Amz-Date", invalidDate)
return checkIAMAuthRequest(s, req, iamerr.IncompleteSignatureInvalidXAmzDate(invalidDate))
})
}
func IAMAuth_date_mismatch(s *S3Conf) error {
testName := "IAMAuth_date_mismatch"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authHdr := req.Header.Get("Authorization")
regExp := regexp.MustCompile("Credential=[^,]+,")
hdr := regExp.ReplaceAllString(authHdr, fmt.Sprintf("Credential=%s/20000101/us-east-1/iam/aws4_request,", s.awsID))
req.Header.Set("Authorization", hdr)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate))
})
}
func IAMAuth_invalid_sha256_payload_hash_ignored(s *S3Conf) error {
testName := "IAMAuth_invalid_sha256_payload_hash_ignored"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("X-Amz-Content-Sha256", "invalid_sha256")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkIAMSuccess(resp)
})
}
func IAMAuth_unsigned_required_header(s *S3Conf) error {
testName := "IAMAuth_unsigned_required_header"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
authorization := req.Header.Get("Authorization")
authorization = strings.Replace(authorization, "SignedHeaders=host;", "SignedHeaders=", 1)
req.Header.Set("Authorization", authorization)
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrMissingHostSignedHeader))
})
}
func IAMAuth_unsigned_non_required_header(s *S3Conf) error {
testName := "IAMAuth_unsigned_non_required_header"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("X-Amz-Copy-Source", "source-bucket/source-key")
req.Header.Set("X-Amz-Tagging", "key=value")
req.Header.Set("X-Custom-Header", "value")
req.Header.Set("X-Another-Custom-Header", "value")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkIAMSuccess(resp)
})
}
func IAMAuth_signature_error_incorrect_secret_key(s *S3Conf) error {
testName := "IAMAuth_signature_error_incorrect_secret_key"
cfg := iamAuthConfig(testName)
cfg.secret = s.awsSecret + "a"
return authHandler(s, cfg, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrSignatureDoesNotMatch))
})
}
func IAMAuth_sigv2_not_supported(s *S3Conf) error {
testName := "IAMAuth_sigv2_not_supported"
return authHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("Authorization", "AWS seed_signature")
return checkIAMAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrUnsupportedSignatureVersion))
})
}
func IAMAuth_with_expect_header(s *S3Conf) error {
testName := "IAMAuth_with_expect_header"
cfg := iamAuthConfig(testName)
cfg.headers = map[string]string{
"Expect": "100-continue",
}
return authHandler(s, cfg, func(req *http.Request) error {
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkIAMSuccess(resp)
})
}
func iamAuthConfig(testName string) *authConfig {
return &authConfig{
testName: testName,
method: http.MethodGet,
path: iamAuthPath,
service: "iam",
region: iamAuthRegion,
date: time.Now().UTC(),
}
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/versity/versitygw/iamapi/iamerr"
)
var integrationIAMUserIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`)
func IAMCreateUser_user_already_exists(s *S3Conf) error {
testName := "IAMCreateUser_user_already_exists"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: &userName,
})
if err != nil {
return err
}
_, err = createIAMUser(client, &iam.CreateUserInput{UserName: &userName})
return checkIAMApiErr(err, iamerr.EntityAlreadyExistsUser(userName))
})
}
func IAMCreateUser_invalid_user_name(s *S3Conf) error {
testName := "IAMCreateUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String("invalid/user"),
})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMCreateUser_long_user_name(s *S3Conf) error {
testName := "IAMCreateUser_long_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(strings.Repeat("a", 65)),
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 64))
})
}
func IAMCreateUser_missing_user_name(s *S3Conf) error {
testName := "IAMCreateUser_missing_user_name"
body := []byte("Action=CreateUser&Version=2010-05-08")
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.ValidationError("1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null"))
})
}
func IAMCreateUser_invalid_tag_key(s *S3Conf) error {
testName := "IAMCreateUser_invalid_tag_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Tags: []iamtypes.Tag{
{Key: aws.String("invalid*key"), Value: aws.String("value")},
},
})
return checkIAMApiErr(err, iamerr.ValidationError("1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+"))
})
}
func IAMCreateUser_invalid_tag_value(s *S3Conf) error {
testName := "IAMCreateUser_invalid_tag_value"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Tags: []iamtypes.Tag{
{Key: aws.String("key"), Value: aws.String("invalid*value")},
},
})
return checkIAMApiErr(err, iamerr.ValidationError("1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"))
})
}
func IAMCreateUser_long_tag_key(s *S3Conf) error {
testName := "IAMCreateUser_long_tag_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Tags: []iamtypes.Tag{
{Key: aws.String(strings.Repeat("k", 129)), Value: aws.String("value")},
},
})
return checkIAMApiErr(err, iamerr.ValidationError("1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128"))
})
}
func IAMCreateUser_long_tag_value(s *S3Conf) error {
testName := "IAMCreateUser_long_tag_value"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Tags: []iamtypes.Tag{
{Key: aws.String("key"), Value: aws.String(strings.Repeat("v", 257))},
},
})
return checkIAMApiErr(err, iamerr.ValidationError("1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256"))
})
}
func IAMCreateUser_duplicate_tag_keys(s *S3Conf) error {
testName := "IAMCreateUser_duplicate_tag_keys"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Tags: []iamtypes.Tag{
{Key: aws.String("key"), Value: aws.String("one")},
{Key: aws.String("KEY"), Value: aws.String("two")},
},
})
return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive."))
})
}
func IAMCreateUser_success(s *S3Conf) error {
testName := "IAMCreateUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
out, err := createIAMUser(client, &iam.CreateUserInput{
UserName: &userName,
Path: aws.String("/"),
Tags: []iamtypes.Tag{
{Key: aws.String("key"), Value: aws.String("value")},
},
})
if err != nil {
return err
}
checkErr := checkCreateUserOutput(out, userName, "/", true)
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMCreateUser_default_path(s *S3Conf) error {
testName := "IAMCreateUser_default_path"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
out, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName})
if err != nil {
return err
}
checkErr := checkCreateUserOutput(out, userName, "/", false)
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMCreateUser_invalid_path(s *S3Conf) error {
testName := "IAMCreateUser_invalid_path"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Path: aws.String("invalid"),
})
return checkIAMApiErr(err, iamerr.InvalidPath("path"))
})
}
func IAMCreateUser_long_path(s *S3Conf) error {
testName := "IAMCreateUser_long_path"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: aws.String(newIAMUserName()),
Path: aws.String("/" + strings.Repeat("a", 511) + "/"),
})
return checkIAMApiErr(err, iamerr.PathTooLong("path", 512))
})
}
func createIAMUser(client *iam.Client, input *iam.CreateUserInput) (*iam.CreateUserOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.CreateUser(ctx, input)
}
func deleteIAMUser(client *iam.Client, userName string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := client.DeleteUser(ctx, &iam.DeleteUserInput{UserName: &userName})
return err
}
func newIAMUserName() string {
return "create-user-" + genRandString(16)
}
func checkCreateUserOutput(out *iam.CreateUserOutput, userName, path string, expectTags bool) error {
if out == nil || out.User == nil {
return fmt.Errorf("expected CreateUser output user")
}
user := out.User
if aws.ToString(user.Path) != path {
return fmt.Errorf("expected user path to be %q, instead got %q", path, aws.ToString(user.Path))
}
if aws.ToString(user.UserName) != userName {
return fmt.Errorf("expected user name to be %q, instead got %q", userName, aws.ToString(user.UserName))
}
expectedARN := "arn:aws:iam::000000000000:user" + path + userName
if aws.ToString(user.Arn) != expectedARN {
return fmt.Errorf("expected user ARN to be %q, instead got %q", expectedARN, aws.ToString(user.Arn))
}
if !integrationIAMUserIDPattern.MatchString(aws.ToString(user.UserId)) {
return fmt.Errorf("expected AWS IAM user id, instead got %q", aws.ToString(user.UserId))
}
if user.CreateDate == nil || user.CreateDate.IsZero() {
return fmt.Errorf("expected user create date")
}
if expectTags {
if len(user.Tags) != 1 || aws.ToString(user.Tags[0].Key) != "key" || aws.ToString(user.Tags[0].Value) != "value" {
return fmt.Errorf("expected user tag key=value, instead got %#v", user.Tags)
}
} else if len(user.Tags) != 0 {
return fmt.Errorf("expected no user tags, instead got %#v", user.Tags)
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected CreateUser response request id")
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package integration
import (
"context"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMDeleteUser_invalid_user_name(s *S3Conf) error {
testName := "IAMDeleteUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
err := deleteIAMUser(client, "invalid/user")
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMDeleteUser_long_user_name(s *S3Conf) error {
testName := "IAMDeleteUser_long_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
err := deleteIAMUser(client, strings.Repeat("a", 129))
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMDeleteUser_non_existing_user(s *S3Conf) error {
testName := "IAMDeleteUser_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
const userName = "asdfadsf"
err := deleteIAMUser(client, userName)
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMDeleteUser_success(s *S3Conf) error {
testName := "IAMDeleteUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
if err := deleteIAMUser(client, userName); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := client.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(userName)})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMGetUser_long_user_name(s *S3Conf) error {
testName := "IAMGetUser_long_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := client.GetUser(ctx, &iam.GetUserInput{
UserName: aws.String(strings.Repeat("a", 129)),
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMGetUser_invalid_user_name(s *S3Conf) error {
testName := "IAMGetUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := client.GetUser(ctx, &iam.GetUserInput{
UserName: aws.String("invalid/user"),
})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMGetUser_non_existing_user(s *S3Conf) error {
testName := "IAMGetUser_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
const userName = "asdkjnfkj"
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err := client.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(userName)})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMGetUser_success(s *S3Conf) error {
testName := "IAMGetUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
_, err := createIAMUser(client, &iam.CreateUserInput{
UserName: &userName,
Tags: []iamtypes.Tag{
{Key: aws.String("team"), Value: aws.String("integration")},
{Key: aws.String("purpose"), Value: aws.String("get-user")},
},
})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := client.GetUser(ctx, &iam.GetUserInput{UserName: &userName})
cancel()
if err != nil {
deleteErr := deleteIAMUser(client, userName)
if deleteErr != nil {
return fmt.Errorf("get user: %v; delete user: %w", err, deleteErr)
}
return err
}
checkErr := func() error {
if out == nil || out.User == nil {
return fmt.Errorf("expected GetUser output user")
}
user := out.User
if aws.ToString(user.Path) != "/" {
return fmt.Errorf("expected user path to be %q, instead got %q", "/", aws.ToString(user.Path))
}
if aws.ToString(user.UserName) != userName {
return fmt.Errorf("expected user name to be %q, instead got %q", userName, aws.ToString(user.UserName))
}
expectedARN := "arn:aws:iam::000000000000:user/" + userName
if aws.ToString(user.Arn) != expectedARN {
return fmt.Errorf("expected user ARN to be %q, instead got %q", expectedARN, aws.ToString(user.Arn))
}
if !integrationIAMUserIDPattern.MatchString(aws.ToString(user.UserId)) {
return fmt.Errorf("expected AWS IAM user id, instead got %q", aws.ToString(user.UserId))
}
if user.CreateDate == nil || user.CreateDate.IsZero() {
return fmt.Errorf("expected user create date")
}
if len(user.Tags) != 2 ||
aws.ToString(user.Tags[0].Key) != "team" || aws.ToString(user.Tags[0].Value) != "integration" ||
aws.ToString(user.Tags[1].Key) != "purpose" || aws.ToString(user.Tags[1].Value) != "get-user" {
return fmt.Errorf("expected user tags team=integration and purpose=get-user, instead got %#v", user.Tags)
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected GetUser response request id")
}
return nil
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMGetUser_root_user(s *S3Conf) error {
testName := "IAMGetUser_root_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
out, err := client.GetUser(ctx, &iam.GetUserInput{UserName: aws.String("")})
if err != nil {
return err
}
if out == nil || out.User == nil {
return fmt.Errorf("expected GetUser output root user")
}
if aws.ToString(out.User.Arn) != "arn:aws:iam::000000000000:root" {
return fmt.Errorf("expected root user ARN, instead got %q", aws.ToString(out.User.Arn))
}
if aws.ToString(out.User.UserId) != "000000000000" {
return fmt.Errorf("expected root user id to be %q, instead got %q", "000000000000", aws.ToString(out.User.UserId))
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected GetUser response request id")
}
return nil
})
}
+367
View File
@@ -0,0 +1,367 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package integration
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMListUsers_invalid_path_prefix(s *S3Conf) error {
testName := "IAMListUsers_invalid_path_prefix"
return iamActionHandler(s, testName, func(client *iam.Client) error {
expected := iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.")
for _, pathPrefix := range []string{"invalid", "/invalid\n"} {
_, err := listIAMUsers(client, &iam.ListUsersInput{PathPrefix: aws.String(pathPrefix)})
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("PathPrefix %q: %w", pathPrefix, checkErr)
}
}
return nil
})
}
func IAMListUsers_long_path_prefix(s *S3Conf) error {
testName := "IAMListUsers_long_path_prefix"
return iamActionHandler(s, testName, func(client *iam.Client) error {
pathPrefix := "/" + strings.Repeat("a", 512)
_, err := listIAMUsers(client, &iam.ListUsersInput{PathPrefix: &pathPrefix})
return checkIAMApiErr(err, iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters."))
})
}
func IAMListUsers_invalid_max_items(s *S3Conf) error {
testName := "IAMListUsers_invalid_max_items"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for _, maxItems := range []int32{-1, 0, 1001} {
_, err := listIAMUsers(client, &iam.ListUsersInput{MaxItems: aws.Int32(maxItems)})
expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems))
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
}
}
return nil
})
}
func IAMListUsers_invalid_max_items_format(s *S3Conf) error {
testName := "IAMListUsers_invalid_max_items_format"
body := []byte(url.Values{
"Action": {"ListUsers"},
"Version": {"2010-05-08"},
"MaxItems": {"not-a-number"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000")
return checkIAMAuthRequest(s, req, expected)
})
}
func IAMListUsers_empty_result(s *S3Conf) error {
testName := "IAMListUsers_empty_result"
return iamActionHandler(s, testName, func(client *iam.Client) error {
pathPrefix := "/list-users-" + genRandString(16) + "/"
input := &iam.ListUsersInput{PathPrefix: &pathPrefix}
first, err := listIAMUsers(client, input)
if err != nil {
return err
}
second, err := listIAMUsers(client, input)
if err != nil {
return err
}
if err := checkIAMListUsersOutput(first); err != nil {
return err
}
if err := checkIAMListUsersOutput(second); err != nil {
return err
}
if len(first.Users) != 0 || len(second.Users) != 0 {
return fmt.Errorf("expected consistent empty results, instead got %v and %v", iamListUserNames(first.Users), iamListUserNames(second.Users))
}
return nil
})
}
func IAMListUsers_success(s *S3Conf) error {
testName := "IAMListUsers_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
path := "/list-users-" + genRandString(16) + "/"
users := map[string]string{"list-users-" + genRandString(16): path}
return withIAMListUsers(client, users, func() error {
out, err := listIAMUsers(client, &iam.ListUsersInput{PathPrefix: &path})
if err != nil {
return err
}
if err := checkIAMListUsersOutput(out); err != nil {
return err
}
return checkIAMListUsers(out.Users, users)
})
})
}
func IAMListUsers_path_prefix(s *S3Conf) error {
testName := "IAMListUsers_path_prefix"
return iamActionHandler(s, testName, func(client *iam.Client) error {
basePath := "/list-users-" + genRandString(16) + "/"
engineeringPath := basePath + "engineering/"
namePrefix := "list-users-" + genRandString(8)
users := map[string]string{
namePrefix + "-root": basePath,
namePrefix + "-z": engineeringPath,
namePrefix + "-a": engineeringPath + "platform/",
namePrefix + "-ops": basePath + "operations/",
}
expected := map[string]string{
namePrefix + "-a": engineeringPath + "platform/",
namePrefix + "-z": engineeringPath,
}
return withIAMListUsers(client, users, func() error {
input := &iam.ListUsersInput{PathPrefix: &engineeringPath}
first, err := listIAMUsers(client, input)
if err != nil {
return err
}
second, err := listIAMUsers(client, input)
if err != nil {
return err
}
if err := checkIAMListUsersOutput(first); err != nil {
return err
}
if err := checkIAMListUsers(first.Users, expected); err != nil {
return err
}
if !reflect.DeepEqual(iamListUserNames(first.Users), iamListUserNames(second.Users)) {
return fmt.Errorf("expected consistent results, instead got %v and %v", iamListUserNames(first.Users), iamListUserNames(second.Users))
}
return nil
})
})
}
func IAMListUsers_pagination(s *S3Conf) error {
testName := "IAMListUsers_pagination"
return iamActionHandler(s, testName, func(client *iam.Client) error {
path := "/list-users-" + genRandString(16) + "/"
users := make(map[string]string, 5)
for range 5 {
users["list-users-"+genRandString(16)] = path
}
return withIAMListUsers(client, users, func() error {
input := iam.ListUsersInput{PathPrefix: &path, MaxItems: aws.Int32(2)}
firstPages, err := collectIAMListUserPages(client, input)
if err != nil {
return err
}
secondPages, err := collectIAMListUserPages(client, input)
if err != nil {
return err
}
if err := checkIAMListUserPages(firstPages, []int{2, 2, 1}, users); err != nil {
return err
}
if !reflect.DeepEqual(iamListUserPageValues(firstPages), iamListUserPageValues(secondPages)) {
return fmt.Errorf("expected consistent pagination results")
}
return nil
})
})
}
func IAMListUsers_path_prefix_pagination(s *S3Conf) error {
testName := "IAMListUsers_path_prefix_pagination"
return iamActionHandler(s, testName, func(client *iam.Client) error {
basePath := "/list-users-" + genRandString(16) + "/"
matchingPath := basePath + "engineering/"
namePrefix := "list-users-" + genRandString(8)
users := map[string]string{
namePrefix + "-outside": basePath,
namePrefix + "-e": matchingPath,
namePrefix + "-d": matchingPath,
namePrefix + "-c": matchingPath + "platform/",
namePrefix + "-b": matchingPath + "storage/",
namePrefix + "-a": matchingPath + "storage/archive/",
namePrefix + "-ops": basePath + "operations/",
}
expected := map[string]string{
namePrefix + "-a": matchingPath + "storage/archive/",
namePrefix + "-b": matchingPath + "storage/",
namePrefix + "-c": matchingPath + "platform/",
namePrefix + "-d": matchingPath,
namePrefix + "-e": matchingPath,
}
return withIAMListUsers(client, users, func() error {
input := iam.ListUsersInput{PathPrefix: &matchingPath, MaxItems: aws.Int32(2)}
firstPages, err := collectIAMListUserPages(client, input)
if err != nil {
return err
}
secondPages, err := collectIAMListUserPages(client, input)
if err != nil {
return err
}
if err := checkIAMListUserPages(firstPages, []int{2, 2, 1}, expected); err != nil {
return err
}
if !reflect.DeepEqual(iamListUserPageValues(firstPages), iamListUserPageValues(secondPages)) {
return fmt.Errorf("expected consistent filtered pagination results")
}
return nil
})
})
}
func listIAMUsers(client *iam.Client, input *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.ListUsers(ctx, input)
}
func withIAMListUsers(client *iam.Client, users map[string]string, test func() error) (err error) {
created := make([]string, 0, len(users))
defer func() {
for _, name := range created {
if deleteErr := deleteIAMUser(client, name); deleteErr != nil {
err = errors.Join(err, fmt.Errorf("delete IAM user %q: %w", name, deleteErr))
}
}
}()
for name, path := range users {
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &name, Path: &path}); err != nil {
return err
}
created = append(created, name)
}
return test()
}
func collectIAMListUserPages(client *iam.Client, input iam.ListUsersInput) ([]*iam.ListUsersOutput, error) {
var pages []*iam.ListUsersOutput
for {
out, err := listIAMUsers(client, &input)
if err != nil {
return nil, err
}
if err := checkIAMListUsersOutput(out); err != nil {
return nil, err
}
pages = append(pages, out)
if !out.IsTruncated {
return pages, nil
}
input.Marker = out.Marker
}
}
func checkIAMListUsersOutput(out *iam.ListUsersOutput) error {
if out == nil {
return fmt.Errorf("expected ListUsers output")
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected ListUsers response request id")
}
if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") {
return fmt.Errorf("expected marker only when ListUsers output is truncated")
}
for _, user := range out.Users {
if aws.ToString(user.Path) == "" || aws.ToString(user.UserName) == "" || aws.ToString(user.UserId) == "" || aws.ToString(user.Arn) == "" || user.CreateDate == nil || user.CreateDate.IsZero() {
return fmt.Errorf("expected all required fields for listed user, instead got %#v", user)
}
if !integrationIAMUserIDPattern.MatchString(aws.ToString(user.UserId)) {
return fmt.Errorf("expected AWS IAM user id, instead got %q", aws.ToString(user.UserId))
}
}
return nil
}
func checkIAMListUsers(users []iamtypes.User, expected map[string]string) error {
if len(users) != len(expected) {
return fmt.Errorf("expected %d users, instead got %d: %v", len(expected), len(users), iamListUserNames(users))
}
names := iamListUserNames(users)
if !sort.StringsAreSorted(names) {
return fmt.Errorf("expected users sorted by username, instead got %v", names)
}
for _, user := range users {
name := aws.ToString(user.UserName)
path, ok := expected[name]
if !ok {
return fmt.Errorf("unexpected listed user %q", name)
}
if aws.ToString(user.Path) != path {
return fmt.Errorf("expected user %q path %q, instead got %q", name, path, aws.ToString(user.Path))
}
if want := "arn:aws:iam::000000000000:user" + path + name; aws.ToString(user.Arn) != want {
return fmt.Errorf("expected user %q ARN %q, instead got %q", name, want, aws.ToString(user.Arn))
}
}
return nil
}
func checkIAMListUserPages(pages []*iam.ListUsersOutput, sizes []int, expected map[string]string) error {
if len(pages) != len(sizes) {
return fmt.Errorf("expected %d pages, instead got %d", len(sizes), len(pages))
}
var users []iamtypes.User
for i, page := range pages {
if len(page.Users) != sizes[i] {
return fmt.Errorf("expected page %d to contain %d users, instead got %d", i+1, sizes[i], len(page.Users))
}
if page.IsTruncated != (i < len(pages)-1) {
return fmt.Errorf("unexpected IsTruncated value on page %d", i+1)
}
users = append(users, page.Users...)
}
return checkIAMListUsers(users, expected)
}
func iamListUserPageValues(pages []*iam.ListUsersOutput) [][]string {
values := make([][]string, len(pages))
for i, page := range pages {
values[i] = append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListUserNames(page.Users)...)
}
return values
}
func iamListUserNames(users []iamtypes.User) []string {
names := make([]string, len(users))
for i, user := range users {
names[i] = aws.ToString(user.UserName)
}
return names
}
+340
View File
@@ -0,0 +1,340 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
vgwv4 "github.com/versity/versitygw/aws/signer/v4"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/internal/sigv4auth"
)
func IAMQueryAuth_success(s *S3Conf) error {
testName := "IAMQueryAuth_success"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
return checkIAMQueryAuthRequest(s, req, nil)
})
}
func IAMQueryAuth_security_token_not_supported(s *S3Conf) error {
testName := "IAMQueryAuth_security_token_not_supported"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
setIAMQueryParameter(req, sigv4auth.QuerySecurityToken, "my_token")
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID))
})
}
func IAMQueryAuth_unsupported_algorithm(s *S3Conf) error {
testName := "IAMQueryAuth_unsupported_algorithm"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
const algorithm = "AWS4-SHA256"
setIAMQueryParameter(req, sigv4auth.QueryAlgorithm, algorithm)
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrUnsupportedQueryAlgorithm))
})
}
func IAMQueryAuth_ECDSA_not_supported(s *S3Conf) error {
testName := "IAMQueryAuth_ECDSA_not_supported"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
setIAMQueryParameter(req, sigv4auth.QueryAlgorithm, sigv4auth.AlgorithmECDSAP256SHA256)
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrUnsupportedQueryAlgorithm))
})
}
func IAMQueryAuth_missing_query_parameters(s *S3Conf) error {
testName := "IAMQueryAuth_missing_query_parameters"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
testCases := []struct {
name string
parameter string
expected iamerr.APIError
}{
{
name: "missing_algorithm",
parameter: sigv4auth.QueryAlgorithm,
expected: iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken),
},
{
name: "missing_credential",
parameter: sigv4auth.QueryCredential,
expected: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QueryCredential),
},
{
name: "missing_date",
parameter: sigv4auth.QueryDate,
expected: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QueryDate),
},
{
name: "missing_signed_headers",
parameter: sigv4auth.QuerySignedHeaders,
expected: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QuerySignedHeaders),
},
{
name: "missing_signature",
parameter: sigv4auth.QuerySignature,
expected: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QuerySignature),
},
}
for _, testCase := range testCases {
testReq := req.Clone(req.Context())
deleteIAMQueryParameter(testReq, testCase.parameter)
if err := checkIAMQueryAuthRequest(s, testReq, testCase.expected); err != nil {
return fmt.Errorf("%s: %w", testCase.name, err)
}
}
return nil
})
}
func IAMQueryAuth_malformed_credential(s *S3Conf) error {
testName := "IAMQueryAuth_malformed_credential"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
const credential = "access/hello/world"
setIAMQueryParameter(req, sigv4auth.QueryCredential, credential)
return checkIAMQueryAuthRequest(s, req, iamerr.IncompleteSignatureMalformedCredential(credential))
})
}
func IAMQueryAuth_credentials_invalid_terminal(s *S3Conf) error {
testName := "IAMQueryAuth_credentials_invalid_terminal"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
if err := changeIAMQueryCredential(req, "aws_request", credTerminator); err != nil {
return err
}
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidTerminal))
})
}
func IAMQueryAuth_credentials_incorrect_service(s *S3Conf) error {
testName := "IAMQueryAuth_credentials_incorrect_service"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
if err := changeIAMQueryCredential(req, "ec2", credService); err != nil {
return err
}
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrIncorrectService))
})
}
func IAMQueryAuth_credentials_incorrect_region(s *S3Conf) error {
testName := "IAMQueryAuth_credentials_incorrect_region"
cfg := iamAuthConfig(testName)
cfg.region = "us-west-1"
return iamQueryAuthHandler(s, cfg, func(req *http.Request) error {
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidRegion))
})
}
func IAMQueryAuth_credentials_invalid_date(s *S3Conf) error {
testName := "IAMQueryAuth_credentials_invalid_date"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
if err := changeIAMQueryCredential(req, "3223423234", credDate); err != nil {
return err
}
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate))
})
}
func IAMQueryAuth_non_existing_access_key(s *S3Conf) error {
testName := "IAMQueryAuth_non_existing_access_key"
cfg := iamAuthConfig(testName)
cfg.access = "a_rarely_existing_access_key_id_a7s86df78as6df89790a8sd7f"
return iamQueryAuthHandler(s, cfg, func(req *http.Request) error {
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID))
})
}
func IAMQueryAuth_invalid_date(s *S3Conf) error {
testName := "IAMQueryAuth_invalid_date"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
const invalidDate = "03032006"
setIAMQueryParameter(req, sigv4auth.QueryDate, invalidDate)
return checkIAMQueryAuthRequest(s, req, iamerr.IncompleteSignatureInvalidXAmzDate(invalidDate))
})
}
func IAMQueryAuth_date_mismatch(s *S3Conf) error {
testName := "IAMQueryAuth_date_mismatch"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
if err := changeIAMQueryCredential(req, "20000101", credDate); err != nil {
return err
}
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate))
})
}
func IAMQueryAuth_unsigned_query_parameter(s *S3Conf) error {
testName := "IAMQueryAuth_unsigned_query_parameter"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
setIAMQueryParameter(req, "ExtraParam", "value")
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrSignatureDoesNotMatch))
})
}
func IAMQueryAuth_incorrect_secret_key(s *S3Conf) error {
testName := "IAMQueryAuth_incorrect_secret_key"
cfg := iamAuthConfig(testName)
cfg.secret = s.awsSecret + "a"
return iamQueryAuthHandler(s, cfg, func(req *http.Request) error {
return checkIAMQueryAuthRequest(s, req, iamerr.GetAPIError(iamerr.ErrSignatureDoesNotMatch))
})
}
func IAMQueryAuth_invalid_sha256_payload_hash_ignored(s *S3Conf) error {
testName := "IAMQueryAuth_invalid_sha256_payload_hash_ignored"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("X-Amz-Content-Sha256", "invalid_sha256")
return checkIAMQueryAuthRequest(s, req, nil)
})
}
func IAMQueryAuth_with_expect_header(s *S3Conf) error {
testName := "IAMQueryAuth_with_expect_header"
return iamQueryAuthHandler(s, iamAuthConfig(testName), func(req *http.Request) error {
req.Header.Set("Expect", "100-continue")
return checkIAMQueryAuthRequest(s, req, nil)
})
}
func iamQueryAuthHandler(s *S3Conf, cfg *authConfig, handler func(req *http.Request) error) error {
runF(cfg.testName)
access, secret, region := s.awsID, s.awsSecret, s.awsRegion
if cfg.access != "" {
access = cfg.access
}
if cfg.secret != "" {
secret = cfg.secret
}
if cfg.region != "" {
region = cfg.region
}
req, err := createIAMQuerySignedRequest(s.endpoint, cfg, access, secret, region)
if err == nil {
err = handler(req)
}
if err != nil {
failF("%v: %v", cfg.testName, err)
return fmt.Errorf("%v: %w", cfg.testName, err)
}
passF(cfg.testName)
return nil
}
func createIAMQuerySignedRequest(endpoint string, cfg *authConfig, access, secret, region string) (*http.Request, error) {
target := strings.TrimRight(endpoint, "/") + "/" + strings.TrimLeft(cfg.path, "/")
req, err := http.NewRequest(cfg.method, target, bytes.NewReader(cfg.body))
if err != nil {
return nil, fmt.Errorf("create IAM query auth request: %w", err)
}
for key, value := range cfg.headers {
req.Header.Set(key, value)
}
payloadHash := cfg.overrideSha256
if payloadHash == "" {
hash := sha256.Sum256(cfg.body)
payloadHash = hex.EncodeToString(hash[:])
}
signer := vgwv4.NewSigner()
signedURL, signedHeaders, _, err := signer.PresignHTTP(
context.Background(),
aws.Credentials{AccessKeyID: access, SecretAccessKey: secret},
req,
payloadHash,
cfg.service,
region,
cfg.date,
nil,
)
if err != nil {
return nil, fmt.Errorf("sign IAM query auth request: %w", err)
}
signedReq, err := http.NewRequest(cfg.method, signedURL, bytes.NewReader(cfg.body))
if err != nil {
return nil, fmt.Errorf("create signed IAM query auth request: %w", err)
}
for key, value := range cfg.headers {
signedReq.Header.Set(key, value)
}
for key, values := range signedHeaders {
signedReq.Header[key] = append([]string(nil), values...)
}
return signedReq, nil
}
func checkIAMQueryAuthRequest(s *S3Conf, req *http.Request, expected iamerr.APIError) error {
if expected != nil {
return checkIAMAuthRequest(s, req, expected)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkIAMSuccess(resp)
}
func setIAMQueryParameter(req *http.Request, parameter, value string) {
query := req.URL.Query()
query.Set(parameter, value)
req.URL.RawQuery = query.Encode()
}
func deleteIAMQueryParameter(req *http.Request, parameter string) {
query := req.URL.Query()
query.Del(parameter)
req.URL.RawQuery = query.Encode()
}
func changeIAMQueryCredential(req *http.Request, value string, index int) error {
query := req.URL.Query()
credential := query.Get(sigv4auth.QueryCredential)
parts := strings.Split(credential, "/")
if len(parts) != 5 {
return fmt.Errorf("unexpected generated IAM query credential %q", credential)
}
parts[index] = value
query.Set(sigv4auth.QueryCredential, strings.Join(parts, "/"))
req.URL.RawQuery = query.Encode()
return nil
}
+201
View File
@@ -0,0 +1,201 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package integration
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMUpdateUser_invalid_user_name(s *S3Conf) error {
testName := "IAMUpdateUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{UserName: aws.String("invalid/user")})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMUpdateUser_long_user_name(s *S3Conf) error {
testName := "IAMUpdateUser_long_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{UserName: aws.String(strings.Repeat("a", 129))})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMUpdateUser_invalid_new_user_name(s *S3Conf) error {
testName := "IAMUpdateUser_invalid_new_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{
UserName: aws.String("asdfadsf"),
NewUserName: aws.String("invalid/user"),
})
return checkIAMApiErr(err, iamerr.InvalidUserName("newUserName"))
})
}
func IAMUpdateUser_long_new_user_name(s *S3Conf) error {
testName := "IAMUpdateUser_long_new_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{
UserName: aws.String("asdfadsf"),
NewUserName: aws.String(strings.Repeat("a", 65)),
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("newUserName", 64))
})
}
func IAMUpdateUser_non_existing_user(s *S3Conf) error {
testName := "IAMUpdateUser_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
const userName = "asdfadsf"
_, err := updateIAMUser(client, &iam.UpdateUserInput{UserName: aws.String(userName)})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMUpdateUser_invalid_new_path(s *S3Conf) error {
testName := "IAMUpdateUser_invalid_new_path"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{
UserName: aws.String("asdfadsf"),
NewPath: aws.String("invalid"),
})
return checkIAMApiErr(err, iamerr.InvalidPath("newPath"))
})
}
func IAMUpdateUser_long_new_path(s *S3Conf) error {
testName := "IAMUpdateUser_long_new_path"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := updateIAMUser(client, &iam.UpdateUserInput{
UserName: aws.String("asdfadsf"),
NewPath: aws.String("/" + strings.Repeat("a", 511) + "/"),
})
return checkIAMApiErr(err, iamerr.PathTooLong("newPath", 512))
})
}
func IAMUpdateUser_new_user_name_already_exists(s *S3Conf) error {
testName := "IAMUpdateUser_new_user_name_already_exists"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
existingUserName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &existingUserName}); err != nil {
deleteErr := deleteIAMUser(client, userName)
if deleteErr != nil {
return fmt.Errorf("create second user: %v; delete first user: %w", err, deleteErr)
}
return err
}
_, updateErr := updateIAMUser(client, &iam.UpdateUserInput{
UserName: &userName,
NewUserName: &existingUserName,
})
checkErr := checkIAMApiErr(updateErr, iamerr.EntityAlreadyExistsUser(existingUserName))
firstDeleteErr := deleteIAMUser(client, userName)
secondDeleteErr := deleteIAMUser(client, existingUserName)
if checkErr != nil {
return checkErr
}
if firstDeleteErr != nil {
return firstDeleteErr
}
return secondDeleteErr
})
}
func IAMUpdateUser_success(s *S3Conf) error {
testName := "IAMUpdateUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
created, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName})
if err != nil {
return err
}
newUserName := newIAMUserName()
newPath := "/updated/"
out, err := updateIAMUser(client, &iam.UpdateUserInput{
UserName: &userName,
NewUserName: &newUserName,
NewPath: &newPath,
})
if err != nil {
deleteErr := deleteIAMUser(client, userName)
if deleteErr != nil {
return fmt.Errorf("update user: %v; delete user: %w", err, deleteErr)
}
return err
}
checkErr := func() error {
if out == nil {
return fmt.Errorf("expected UpdateUser output")
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected UpdateUser response request id")
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
updated, err := client.GetUser(ctx, &iam.GetUserInput{UserName: &newUserName})
cancel()
if err != nil {
return err
}
if updated == nil || updated.User == nil || created == nil || created.User == nil {
return fmt.Errorf("expected created and updated users")
}
if aws.ToString(updated.User.UserName) != newUserName || aws.ToString(updated.User.Path) != newPath {
return fmt.Errorf("expected updated user name/path %q/%q, instead got %q/%q", newUserName, newPath, aws.ToString(updated.User.UserName), aws.ToString(updated.User.Path))
}
expectedARN := "arn:aws:iam::000000000000:user" + newPath + newUserName
if aws.ToString(updated.User.Arn) != expectedARN {
return fmt.Errorf("expected updated user ARN %q, instead got %q", expectedARN, aws.ToString(updated.User.Arn))
}
if updated.User.CreateDate == nil || created.User.CreateDate == nil {
return fmt.Errorf("expected created and updated user create dates")
}
if aws.ToString(updated.User.UserId) != aws.ToString(created.User.UserId) || !updated.User.CreateDate.Equal(*created.User.CreateDate) {
return fmt.Errorf("expected UpdateUser to preserve user id and create date")
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
_, err = client.GetUser(ctx, &iam.GetUserInput{UserName: &userName})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
}()
deleteErr := deleteIAMUser(client, newUserName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func updateIAMUser(client *iam.Client, input *iam.UpdateUserInput) (*iam.UpdateUserOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.UpdateUser(ctx, input)
}
+5
View File
@@ -27,6 +27,7 @@ import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go/middleware"
)
@@ -153,6 +154,10 @@ func (c *S3Conf) GetClient() *s3.Client {
})
}
func (c *S3Conf) GetIAMClient() *iam.Client {
return iam.NewFromConfig(c.Config())
}
func (c *S3Conf) GetPresignClient() *s3.PresignClient {
return s3.NewPresignClient(c.GetClient())
}
+137 -2
View File
@@ -49,12 +49,15 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/cespare/xxhash/v2"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/s3err"
"github.com/zeebo/xxh3"
"golang.org/x/sync/errgroup"
@@ -297,6 +300,19 @@ func actionHandlerNoSetup(s *S3Conf, testName string, handler func(s3client *s3.
return handlerErr
}
func iamActionHandler(s *S3Conf, testName string, handler func(client *iam.Client) error) error {
runF(testName)
err := handler(s.GetIAMClient())
if err != nil {
failF("%v: %v", testName, err)
return fmt.Errorf("%v: %w", testName, err)
}
passF(testName)
return nil
}
type authConfig struct {
testName string
path string
@@ -304,13 +320,26 @@ type authConfig struct {
overrideSha256 string
body []byte
service string
access string
secret string
region string
date time.Time
headers map[string]string
}
func authHandler(s *S3Conf, cfg *authConfig, handler func(req *http.Request) error) error {
runF(cfg.testName)
req, err := createSignedReq(cfg.method, s.endpoint, cfg.path, s.awsID, s.awsSecret, cfg.service, s.awsRegion, cfg.overrideSha256, cfg.body, cfg.date, cfg.headers)
access, secret, region := s.awsID, s.awsSecret, s.awsRegion
if cfg.access != "" {
access = cfg.access
}
if cfg.secret != "" {
secret = cfg.secret
}
if cfg.region != "" {
region = cfg.region
}
req, err := createSignedReq(cfg.method, s.endpoint, cfg.path, access, secret, cfg.service, region, cfg.overrideSha256, cfg.body, cfg.date, cfg.headers)
if err != nil {
failF("%v: %v", cfg.testName, err)
return fmt.Errorf("%v: %w", cfg.testName, err)
@@ -365,7 +394,12 @@ func createSignedReq(method, endpoint, path, access, secret, service, region, ov
hexPayload = hex.EncodeToString(hashedPayload[:])
}
req.Header.Set("X-Amz-Content-Sha256", hexPayload)
// x-amz-content-sha256 is an S3 signing header. Other services still use
// hexPayload in the canonical request, but should not send or sign this
// header unless the caller explicitly supplies it.
if service == "s3" {
req.Header.Set("X-Amz-Content-Sha256", hexPayload)
}
for key, val := range headers {
req.Header.Add(key, val)
}
@@ -431,6 +465,16 @@ type APIErrorResponse struct {
HostID string `xml:"HostId,omitempty"`
}
type IAMErrorResponse struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error struct {
Type string
Code string
Message string
}
RequestID string `xml:"RequestId"`
}
func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error {
apiErr := expected.BaseError()
body, err := io.ReadAll(resp.Body)
@@ -452,6 +496,62 @@ func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error
return compareS3ApiError(expected, &errResp)
}
func checkIAMAuthRequest(s *S3Conf, req *http.Request, expected iamerr.APIError) error {
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkHTTPResponseIAMErr(resp, expected)
}
func checkHTTPResponseIAMErr(resp *http.Response, expected iamerr.APIError) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body.Close()
var errResp IAMErrorResponse
err = xml.Unmarshal(body, &errResp)
if err != nil {
return err
}
if resp.StatusCode != expected.StatusCode() {
return fmt.Errorf("expected response status code to be %v, instead got %v", expected.StatusCode(), resp.StatusCode)
}
if errResp.XMLName.Space != iamerr.Namespace {
return fmt.Errorf("expected IAM error namespace, instead got %q", errResp.XMLName.Space)
}
if errResp.RequestID == "" {
return fmt.Errorf("expected IAM error response request id")
}
expectedBody := expected.XMLBody(errResp.RequestID)
if string(body) != string(expectedBody) {
return fmt.Errorf("expected IAM error response body to be %q, instead got %q", expectedBody, body)
}
return nil
}
// isSuccessStatus returns true for 2xx HTTP status codes.
func isSuccessStatus(statusCode int) bool {
return statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices
}
func checkIAMSuccess(resp *http.Response) error {
defer resp.Body.Close()
if !isSuccessStatus(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("expected response status code to be %v, instead got %v: %s", http.StatusOK, resp.StatusCode, body)
}
return nil
}
// websiteGet issues a plain HTTP GET to the dedicated website endpoint.
// The bucket is resolved from the request URL host. No S3 signing is applied.
func websiteGet(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
@@ -799,6 +899,41 @@ func checkSdkApiErr(err error, code string) error {
return err
}
func checkIAMApiErr(err error, expected iamerr.APIError) error {
if err == nil {
return fmt.Errorf("expected IAM API error, instead got nil")
}
var apiErr smithy.APIError
if !errors.As(err, &apiErr) {
return fmt.Errorf("expected IAM API error, instead got: %w", err)
}
expectedErr, ok := expected.(iamerr.Error)
if !ok {
return fmt.Errorf("expected concrete IAM error, got %T", expected)
}
if apiErr.ErrorCode() != expectedErr.Code {
return fmt.Errorf("expected IAM error code to be %q, instead got %q", expectedErr.Code, apiErr.ErrorCode())
}
if apiErr.ErrorMessage() != expectedErr.Message {
return fmt.Errorf("expected IAM error message to be %q, instead got %q", expectedErr.Message, apiErr.ErrorMessage())
}
var responseErr *awshttp.ResponseError
if !errors.As(err, &responseErr) {
return fmt.Errorf("expected IAM HTTP response error, instead got: %w", err)
}
if responseErr.HTTPStatusCode() != expected.StatusCode() {
return fmt.Errorf("expected IAM response status code to be %v, instead got %v", expected.StatusCode(), responseErr.HTTPStatusCode())
}
if responseErr.ServiceRequestID() == "" {
return fmt.Errorf("expected IAM error response request id")
}
return nil
}
func putObjects(client *s3.Client, objs []string, bucket string) ([]types.Object, error) {
var contents []types.Object
var size int64