diff --git a/.github/workflows/functional-iam.yml b/.github/workflows/functional-iam.yml
new file mode 100644
index 00000000..64d4b80b
--- /dev/null
+++ b/.github/workflows/functional-iam.yml
@@ -0,0 +1,50 @@
+name: IAM functional tests
+permissions:
+ contents: read
+on: pull_request
+
+jobs:
+ build:
+ name: RunIAMTests
+ runs-on: ubuntu-latest
+ services:
+ vault:
+ image: hashicorp/vault:1.21.4@sha256:6c77f568e6b6310d5bc68befb5711b9215c574de7da489e7c24332581176888b
+ env:
+ VAULT_ADDR: http://127.0.0.1:8200
+ VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
+ VAULT_DEV_ROOT_TOKEN_ID: iam-ci-root
+ ports:
+ - 8200:8200
+ options: >-
+ --cap-add=IPC_LOCK
+ --health-cmd "vault status"
+ --health-interval 2s
+ --health-timeout 2s
+ --health-retries 15
+ steps:
+
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: 'stable'
+ id: go
+
+ - name: Get Dependencies
+ run: |
+ go mod download
+
+ - name: Build and Run
+ env:
+ VAULT_ADDR: http://127.0.0.1:8200
+ VAULT_TOKEN: iam-ci-root
+ run: |
+ make testbin
+ ./runiamtests.sh
+
+ - name: Coverage Report
+ run: |
+ go tool covdata percent -i=/tmp/iam.covdata,/tmp/iam.https.covdata,/tmp/iam.vault.covdata
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index ac310670..a4d63798 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -23,5 +23,5 @@ jobs:
if [ "$rc" -ne 0 ]; then
overall_rc="$rc"
fi
- done < <(find . \( -path './tests/*.sh' -o -path './tests/*/*.sh' \) -print0)
+ done < <(find . \( -path './runiamtests.sh' -o -path './tests/*.sh' -o -path './tests/*/*.sh' \) -print0)
exit "$overall_rc"
diff --git a/auth/iam_internal.go b/auth/iam_internal.go
index 9843bc58..f9901de2 100644
--- a/auth/iam_internal.go
+++ b/auth/iam_internal.go
@@ -16,14 +16,11 @@ package auth
import (
"encoding/json"
- "errors"
"fmt"
- "io/fs"
- "os"
- "path/filepath"
"sort"
"sync"
- "time"
+
+ "github.com/versity/versitygw/internal/iamstore"
)
const (
@@ -40,13 +37,10 @@ type IAMServiceInternal struct {
// IAM service. All account updates should be sent to a single
// gateway instance if possible.
sync.RWMutex
- dir string
+ engine *iamstore.Engine[iAMConfig]
rootAcc Account
}
-// UpdateAcctFunc accepts the current data and returns the new data to be stored
-type UpdateAcctFunc func([]byte) ([]byte, error)
-
// iAMConfig stores all internal IAM accounts
type iAMConfig struct {
AccessAccounts map[string]Account `json:"accessAccounts"`
@@ -56,16 +50,16 @@ var _ IAMService = &IAMServiceInternal{}
// NewInternal creates a new instance for the Internal IAM service
func NewInternal(rootAcc Account, dir string) (*IAMServiceInternal, error) {
- i := &IAMServiceInternal{
- dir: dir,
- rootAcc: rootAcc,
- }
-
- err := i.initIAM()
+ engine, err := iamstore.New(dir, iamFile, iamBackupFile, defaultIAMConfig(), normalizeIAMConfig)
if err != nil {
return nil, fmt.Errorf("init iam: %w", err)
}
+ i := &IAMServiceInternal{
+ engine: engine,
+ rootAcc: rootAcc,
+ }
+
return i, nil
}
@@ -79,7 +73,7 @@ func (s *IAMServiceInternal) CreateAccount(account Account) error {
s.Lock()
defer s.Unlock()
- return s.storeIAM(func(data []byte) ([]byte, error) {
+ return s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := parseIAM(data)
if err != nil {
return nil, fmt.Errorf("get iam data: %w", err)
@@ -110,7 +104,7 @@ func (s *IAMServiceInternal) GetUserAccount(access string) (Account, error) {
s.RLock()
defer s.RUnlock()
- conf, err := s.getIAM()
+ conf, err := s.engine.GetIAM()
if err != nil {
return Account{}, fmt.Errorf("get iam data: %w", err)
}
@@ -129,7 +123,7 @@ func (s *IAMServiceInternal) UpdateUserAccount(access string, props MutableProps
s.Lock()
defer s.Unlock()
- return s.storeIAM(func(data []byte) ([]byte, error) {
+ return s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := parseIAM(data)
if err != nil {
return nil, fmt.Errorf("get iam data: %w", err)
@@ -158,7 +152,7 @@ func (s *IAMServiceInternal) DeleteUserAccount(access string) error {
s.Lock()
defer s.Unlock()
- return s.storeIAM(func(data []byte) ([]byte, error) {
+ return s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := parseIAM(data)
if err != nil {
return nil, fmt.Errorf("get iam data: %w", err)
@@ -180,7 +174,7 @@ func (s *IAMServiceInternal) ListUserAccounts() ([]Account, error) {
s.RLock()
defer s.RUnlock()
- conf, err := s.getIAM()
+ conf, err := s.engine.GetIAM()
if err != nil {
return []Account{}, fmt.Errorf("get iam data: %w", err)
}
@@ -211,145 +205,16 @@ func (s *IAMServiceInternal) Shutdown() error {
return nil
}
-const (
- iamMode = 0600
-)
-
-func (s *IAMServiceInternal) initIAM() error {
- fname := filepath.Join(s.dir, iamFile)
-
- _, err := os.ReadFile(fname)
- if errors.Is(err, fs.ErrNotExist) {
- b, err := json.Marshal(iAMConfig{AccessAccounts: map[string]Account{}})
- if err != nil {
- return fmt.Errorf("marshal default iam: %w", err)
- }
- err = os.WriteFile(fname, b, iamMode)
- if err != nil {
- return fmt.Errorf("write default iam: %w", err)
- }
- }
-
- return nil
-}
-
-func (s *IAMServiceInternal) getIAM() (iAMConfig, error) {
- b, err := s.readIAMData()
- if err != nil {
- return iAMConfig{}, err
- }
-
- return parseIAM(b)
-}
-
func parseIAM(b []byte) (iAMConfig, error) {
- var conf iAMConfig
- if err := json.Unmarshal(b, &conf); err != nil {
- return iAMConfig{}, fmt.Errorf("failed to parse the config file: %w", err)
- }
+ return iamstore.ParseIAM(b, normalizeIAMConfig)
+}
+func defaultIAMConfig() iAMConfig {
+ return iAMConfig{AccessAccounts: map[string]Account{}}
+}
+
+func normalizeIAMConfig(conf *iAMConfig) {
if conf.AccessAccounts == nil {
conf.AccessAccounts = make(map[string]Account)
}
-
- return conf, nil
-}
-
-const (
- backoff = 100 * time.Millisecond
- maxretry = 300
-)
-
-func (s *IAMServiceInternal) readIAMData() ([]byte, error) {
- // We are going to be racing with other running gateways without any
- // coordination. So we might find the file does not exist at times.
- // For this case we need to retry for a while assuming the other gateway
- // will eventually write the file. If it doesn't after the max retries,
- // then we will return the error.
-
- retries := 0
-
- for {
- b, err := os.ReadFile(filepath.Join(s.dir, iamFile))
- if errors.Is(err, fs.ErrNotExist) {
- // racing with someone else updating
- // keep retrying after backoff
- retries++
- if retries < maxretry {
- time.Sleep(backoff)
- continue
- }
- return nil, fmt.Errorf("read iam file: %w", err)
- }
- if err != nil {
- return nil, err
- }
-
- return b, nil
- }
-}
-
-func (s *IAMServiceInternal) storeIAM(update UpdateAcctFunc) error {
- // We are going to be racing with other running gateways without any
- // coordination. So the strategy here is to read the current file data,
- // update the data, write back out to a temp file, then rename the
- // temp file to the original file. This rename will replace the
- // original file with the new file. This is atomic and should always
- // allow for a consistent view of the data. There is a small
- // window where the file could be read and then updated by
- // another process. In this case any updates the other process did
- // will be lost. This is a limitation of the internal IAM service.
- // This should be rare, and even when it does happen should result
- // in a valid IAM file, just without the other process's updates.
-
- iamFname := filepath.Join(s.dir, iamFile)
- backupFname := filepath.Join(s.dir, iamBackupFile)
-
- b, err := os.ReadFile(iamFname)
- if err != nil && !errors.Is(err, fs.ErrNotExist) {
- return fmt.Errorf("read iam file: %w", err)
- }
-
- // save copy of data
- datacopy := make([]byte, len(b))
- copy(datacopy, b)
-
- // make a backup copy in case something happens
- err = s.writeUsingTempFile(b, backupFname)
- if err != nil {
- return fmt.Errorf("write backup iam file: %w", err)
- }
-
- b, err = update(b)
- if err != nil {
- return fmt.Errorf("update iam data: %w", err)
- }
-
- err = s.writeUsingTempFile(b, iamFname)
- if err != nil {
- return fmt.Errorf("write iam file: %w", err)
- }
-
- return nil
-}
-
-func (s *IAMServiceInternal) writeUsingTempFile(b []byte, fname string) error {
- f, err := os.CreateTemp(s.dir, iamFile)
- if err != nil {
- return fmt.Errorf("create temp file: %w", err)
- }
- defer os.Remove(f.Name())
-
- _, err = f.Write(b)
- f.Close()
- if err != nil {
- return fmt.Errorf("write temp file: %w", err)
- }
-
- err = os.Rename(f.Name(), fname)
- if err != nil {
- return fmt.Errorf("rename temp file: %w", err)
- }
-
- return nil
}
diff --git a/aws/signer/v4/v4.go b/aws/signer/v4/v4.go
index 6e265e29..03beebae 100644
--- a/aws/signer/v4/v4.go
+++ b/aws/signer/v4/v4.go
@@ -494,15 +494,15 @@ func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, he
}
func (s *httpSigner) shouldSignHeader(header string, rule v4Internal.Rule) bool {
- if rule.IsValid(header) {
- return true
- }
if strings.EqualFold(header, authorizationHeader) {
return false
}
- return slices.ContainsFunc(s.SignedHdrs, func(signedHeader string) bool {
- return strings.EqualFold(signedHeader, header)
- })
+ if s.SignedHdrs != nil {
+ return slices.ContainsFunc(s.SignedHdrs, func(signedHeader string) bool {
+ return strings.EqualFold(signedHeader, header)
+ })
+ }
+ return rule.IsValid(header)
}
func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string {
diff --git a/aws/signer/v4/v4_test.go b/aws/signer/v4/v4_test.go
index d321048d..1fe0bb20 100644
--- a/aws/signer/v4/v4_test.go
+++ b/aws/signer/v4/v4_test.go
@@ -180,6 +180,30 @@ func TestSignRequest(t *testing.T) {
}
}
+func TestSignRequestUsesExplicitSignedHeaders(t *testing.T) {
+ req, payloadHash := buildRequest("dynamodb", "us-east-1", "{}")
+ reqWithUnsignedHeaders, _ := buildRequest("dynamodb", "us-east-1", "{}")
+ reqWithUnsignedHeaders.Header.Set("Content-Type", "text/plain")
+ reqWithUnsignedHeaders.Header.Set("X-Unsigned-Header", "ignored")
+ signer := NewSigner()
+ signedHdrs := []string{"host", "x-amz-date"}
+
+ for _, request := range []*http.Request{req, reqWithUnsignedHeaders} {
+ _, err := signer.SignHTTP(context.Background(), testCredentials, request, payloadHash, "dynamodb", "us-east-1", time.Unix(0, 0), signedHdrs)
+ if err != nil {
+ t.Fatalf("expect no error, got %v", err)
+ }
+ }
+
+ authorization := req.Header.Get("Authorization")
+ if !strings.Contains(authorization, "SignedHeaders=host;x-amz-date,") {
+ t.Fatalf("expected only explicit signed headers, got %q", authorization)
+ }
+ if authorization != reqWithUnsignedHeaders.Header.Get("Authorization") {
+ t.Fatalf("unsigned headers changed the signature")
+ }
+}
+
func TestBuildCanonicalRequest(t *testing.T) {
req, _ := buildRequest("dynamodb", "us-east-1", "{}")
req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a"
diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go
new file mode 100644
index 00000000..0b5aa8a2
--- /dev/null
+++ b/cmd/versitygw/iam.go
@@ -0,0 +1,185 @@
+// 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 main
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+ "github.com/versity/versitygw/embedgw"
+)
+
+var (
+ iamServerDir string
+ iamServerVaultEndpointURL string
+ iamServerVaultNamespace string
+ iamServerVaultSecretStoragePath string
+ iamServerVaultSecretStorageNS string
+ iamServerVaultAuthMethod string
+ iamServerVaultAuthNamespace string
+ iamServerVaultMountPath string
+ iamServerVaultRootToken string
+ iamServerVaultRoleID string
+ iamServerVaultRoleSecret string
+ iamServerVaultServerCert string
+ iamServerVaultClientCert string
+ iamServerVaultClientCertKey string
+)
+
+func iamCommand() *cli.Command {
+ return &cli.Command{
+ Name: "iam",
+ Usage: "IAM API server",
+ Description: "Run the standalone IAM API server.",
+ Action: runIAM,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "dir",
+ Usage: "directory path for file-backed IAM storage",
+ EnvVars: []string{"VGW_IAM_DIR"},
+ Destination: &iamServerDir,
+ },
+ &cli.StringFlag{
+ Name: "vault-endpoint-url",
+ Usage: "vault server url for IAM storage",
+ EnvVars: []string{"VGW_IAM_VAULT_ENDPOINT_URL"},
+ Destination: &iamServerVaultEndpointURL,
+ },
+ &cli.StringFlag{
+ Name: "vault-namespace",
+ Usage: "fallback vault namespace for IAM storage (overridden by vault-auth-namespace / vault-secret-storage-namespace)",
+ EnvVars: []string{"VGW_IAM_VAULT_NAMESPACE"},
+ Destination: &iamServerVaultNamespace,
+ },
+ &cli.StringFlag{
+ Name: "vault-secret-storage-path",
+ Usage: "vault KV v2 path prefix for IAM user storage (default: iam)",
+ EnvVars: []string{"VGW_IAM_VAULT_SECRET_STORAGE_PATH"},
+ Destination: &iamServerVaultSecretStoragePath,
+ },
+ &cli.StringFlag{
+ Name: "vault-secret-storage-namespace",
+ Usage: "vault namespace for KV v2 IAM storage (overrides vault-namespace)",
+ EnvVars: []string{"VGW_IAM_VAULT_SECRET_STORAGE_NAMESPACE"},
+ Destination: &iamServerVaultSecretStorageNS,
+ },
+ &cli.StringFlag{
+ Name: "vault-auth-method",
+ Usage: "vault auth method mount path (default: approle)",
+ EnvVars: []string{"VGW_IAM_VAULT_AUTH_METHOD"},
+ Destination: &iamServerVaultAuthMethod,
+ },
+ &cli.StringFlag{
+ Name: "vault-auth-namespace",
+ Usage: "vault namespace for AppRole login (overrides vault-namespace)",
+ EnvVars: []string{"VGW_IAM_VAULT_AUTH_NAMESPACE"},
+ Destination: &iamServerVaultAuthNamespace,
+ },
+ &cli.StringFlag{
+ Name: "vault-mount-path",
+ Usage: "vault KV v2 engine mount path (default: kv-v2)",
+ EnvVars: []string{"VGW_IAM_VAULT_MOUNT_PATH"},
+ Destination: &iamServerVaultMountPath,
+ },
+ &cli.StringFlag{
+ Name: "vault-root-token",
+ Usage: "vault root token for authentication (mutually exclusive with vault-role-id/vault-role-secret)",
+ EnvVars: []string{"VGW_IAM_VAULT_ROOT_TOKEN"},
+ Destination: &iamServerVaultRootToken,
+ },
+ &cli.StringFlag{
+ Name: "vault-role-id",
+ Usage: "vault AppRole role ID for authentication",
+ EnvVars: []string{"VGW_IAM_VAULT_ROLE_ID"},
+ Destination: &iamServerVaultRoleID,
+ },
+ &cli.StringFlag{
+ Name: "vault-role-secret",
+ Usage: "vault AppRole secret ID for authentication",
+ EnvVars: []string{"VGW_IAM_VAULT_ROLE_SECRET"},
+ Destination: &iamServerVaultRoleSecret,
+ },
+ &cli.StringFlag{
+ Name: "vault-server-cert",
+ Usage: "PEM-encoded vault server TLS certificate for verification",
+ EnvVars: []string{"VGW_IAM_VAULT_SERVER_CERT"},
+ Destination: &iamServerVaultServerCert,
+ },
+ &cli.StringFlag{
+ Name: "vault-client-cert",
+ Usage: "PEM-encoded client TLS certificate presented to vault",
+ EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT"},
+ Destination: &iamServerVaultClientCert,
+ },
+ &cli.StringFlag{
+ Name: "vault-client-cert-key",
+ Usage: "PEM-encoded private key for vault-client-cert",
+ EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT_KEY"},
+ Destination: &iamServerVaultClientCertKey,
+ },
+ &cli.BoolFlag{
+ Name: "quiet",
+ Usage: "silence stdout request logging output",
+ EnvVars: []string{"VGW_QUIET"},
+ Destination: &quiet,
+ Aliases: []string{"q"},
+ },
+ },
+ }
+}
+
+func runIAM(ctx *cli.Context) error {
+ if pprof != "" {
+ go func() {
+ log.Printf("pprof: listening on %s", pprof)
+ if err := http.ListenAndServe(pprof, nil); err != nil {
+ log.Printf("pprof: server exited: %v", err)
+ }
+ }()
+ }
+
+ return embedgw.RunIAMAPI(ctx.Context, &embedgw.IAMConfig{
+ RootUserAccess: rootUserAccess,
+ RootUserSecret: rootUserSecret,
+ Ports: ports,
+ MaxConnections: maxConnections,
+ MaxRequests: maxRequests,
+ CertFile: certFile,
+ KeyFile: keyFile,
+ Debug: debug,
+ Quiet: quiet,
+ KeepAlive: keepAlive,
+ HealthPath: healthPath,
+ SocketPerm: socketPerm,
+ IAMDir: iamServerDir,
+ VaultEndpointURL: iamServerVaultEndpointURL,
+ VaultNamespace: iamServerVaultNamespace,
+ VaultSecretStoragePath: iamServerVaultSecretStoragePath,
+ VaultSecretStorageNamespace: iamServerVaultSecretStorageNS,
+ VaultAuthMethod: iamServerVaultAuthMethod,
+ VaultAuthNamespace: iamServerVaultAuthNamespace,
+ VaultMountPath: iamServerVaultMountPath,
+ VaultRootToken: iamServerVaultRootToken,
+ VaultRoleID: iamServerVaultRoleID,
+ VaultRoleSecret: iamServerVaultRoleSecret,
+ VaultServerCert: iamServerVaultServerCert,
+ VaultClientCert: iamServerVaultClientCert,
+ VaultClientCertKey: iamServerVaultClientCertKey,
+ Version: Version,
+ Build: Build,
+ BuildTime: BuildTime,
+ })
+}
diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go
index bed7ab0f..f366fa69 100644
--- a/cmd/versitygw/main.go
+++ b/cmd/versitygw/main.go
@@ -115,16 +115,7 @@ func main() {
app := initApp()
- app.Commands = []*cli.Command{
- posixCommand(),
- scoutfsCommand(),
- s3Command(),
- azureCommand(),
- pluginCommand(),
- adminCommand(),
- testCommand(),
- utilsCommand(),
- }
+ app.Commands = initCommands()
ctx, cancel := context.WithCancel(context.Background())
go func() {
@@ -138,6 +129,20 @@ func main() {
}
}
+func initCommands() []*cli.Command {
+ return []*cli.Command{
+ posixCommand(),
+ scoutfsCommand(),
+ s3Command(),
+ azureCommand(),
+ iamCommand(),
+ pluginCommand(),
+ adminCommand(),
+ testCommand(),
+ utilsCommand(),
+ }
+}
+
func initApp() *cli.App {
return &cli.App{
EnableBashCompletion: true,
diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go
index 8eadca23..8cc7826e 100644
--- a/cmd/versitygw/test.go
+++ b/cmd/versitygw/test.go
@@ -196,9 +196,14 @@ func initTestCommands() []*cli.Command {
Usage: "Tests scoutfs full flow",
Action: getAction(integration.TestScoutfs),
},
+ {
+ Name: "gw-iam",
+ Usage: "Tests gateway IAM service integration",
+ Action: getAction(integration.TestGatewayIAM),
+ },
{
Name: "iam",
- Usage: "Tests iam service",
+ Usage: "Tests standalone IAM API integration",
Action: getAction(integration.TestIAM),
},
{
diff --git a/embedgw/iam.go b/embedgw/iam.go
new file mode 100644
index 00000000..959217c7
--- /dev/null
+++ b/embedgw/iam.go
@@ -0,0 +1,384 @@
+// 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 embedgw
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "sync/atomic"
+
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi"
+ "github.com/versity/versitygw/iamapi/storage"
+ "github.com/versity/versitygw/s3api/utils"
+)
+
+const iamTitle = "VersityGW IAM API"
+
+// IAMConfig holds all configuration options for running the VersityGW IAM API.
+type IAMConfig struct {
+ // RootUserAccess is the access key ID used to authenticate IAM API
+ // requests. Required.
+ RootUserAccess string
+ // RootUserSecret is the secret access key used to authenticate IAM API
+ // requests. Required.
+ RootUserSecret string
+
+ // Ports is the list of IAM API listening addresses. Each entry accepts
+ // the same formats as Config.Ports: "host:port", ":port", file-backed
+ // UNIX socket paths, or Linux abstract namespace sockets prefixed with
+ // "@". Required.
+ Ports []string
+
+ // MaxConnections is the maximum number of concurrent TCP connections
+ // accepted by the IAM API server.
+ MaxConnections int
+ // MaxRequests is the maximum number of concurrent in-flight IAM API
+ // requests. Should not exceed MaxConnections.
+ MaxRequests int
+
+ // CertFile is the path to the TLS certificate file for the IAM API server.
+ // Both CertFile and KeyFile must be provided together to enable TLS.
+ CertFile string
+ // KeyFile is the path to the TLS private key file for the IAM API server.
+ KeyFile string
+
+ // Debug enables verbose request/response debug logging.
+ Debug bool
+ // Quiet suppresses per-request summary logging and startup output.
+ Quiet bool
+ // KeepAlive enables HTTP keep-alive on IAM API connections.
+ KeepAlive bool
+
+ // HealthPath is the URL path for unauthenticated health-check requests
+ // (e.g. "/healthz"). The endpoint returns HTTP 200 for GET requests.
+ HealthPath string
+
+ // SocketPerm is the octal file-mode string for file-backed UNIX domain
+ // socket permissions. It has no effect on TCP/IP addresses or Linux
+ // abstract namespace sockets.
+ SocketPerm string
+
+ // IAMDir enables local file-backed IAM API storage. Set to the directory
+ // path where the IAM API user database is stored.
+ IAMDir string
+
+ // VaultEndpointURL enables Vault-backed IAM API storage.
+ VaultEndpointURL string
+ // VaultNamespace is the fallback Vault namespace used when the specific
+ // auth or secret-storage namespace is not set.
+ VaultNamespace string
+ // VaultSecretStoragePath is the KV v2 path prefix under which IAM users
+ // are stored (defaults to "iam").
+ VaultSecretStoragePath string
+ // VaultSecretStorageNamespace overrides VaultNamespace for KV operations.
+ VaultSecretStorageNamespace string
+ // VaultAuthMethod is the AppRole mount path (defaults to "approle").
+ VaultAuthMethod string
+ // VaultAuthNamespace overrides VaultNamespace for AppRole login.
+ VaultAuthNamespace string
+ // VaultMountPath is the KV v2 engine mount path (defaults to "kv-v2").
+ VaultMountPath string
+ // VaultRootToken authenticates with a root token instead of AppRole.
+ VaultRootToken string
+ // VaultRoleID is the AppRole role ID.
+ VaultRoleID string
+ // VaultRoleSecret is the AppRole secret ID.
+ VaultRoleSecret string
+ // VaultServerCert is the PEM-encoded Vault server TLS certificate for
+ // verification.
+ VaultServerCert string
+ // VaultClientCert is the PEM-encoded client TLS certificate presented to
+ // Vault.
+ VaultClientCert string
+ // VaultClientCertKey is the PEM-encoded private key for VaultClientCert.
+ VaultClientCertKey string
+
+ // SigHup is an optional channel that signals the IAM API to reload TLS
+ // certificates. When nil, this feature is disabled.
+ SigHup <-chan struct{}
+
+ // Version, Build, and BuildTime are displayed in the startup banner.
+ // All three are optional.
+ Version string
+ Build string
+ BuildTime string
+}
+
+var iamAPIRunning atomic.Bool
+
+// RunIAMAPI starts the VersityGW IAM API with the supplied configuration. It
+// blocks until ctx is cancelled, or an error occurs. The server is gracefully
+// shut down before the function returns.
+//
+// Only one IAM API instance may run per process at a time. Calling RunIAMAPI
+// concurrently or a second time before the first call returns will return an
+// error.
+func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error {
+ if cfg == nil {
+ return fmt.Errorf("iam config is required")
+ }
+ if !iamAPIRunning.CompareAndSwap(false, true) {
+ return fmt.Errorf("embedgw: RunIAMAPI is already running; only one instance per process is supported")
+ }
+ defer iamAPIRunning.Store(false)
+
+ if cfg.MaxConnections < 1 {
+ return fmt.Errorf("max-connections must be positive")
+ }
+ if cfg.MaxRequests < 1 {
+ return fmt.Errorf("max-requests must be positive")
+ }
+ if cfg.MaxRequests > cfg.MaxConnections {
+ log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for IAM API to panic before throttling requests",
+ cfg.MaxRequests, cfg.MaxConnections)
+ }
+ if len(cfg.Ports) == 0 {
+ return fmt.Errorf("no ports specified")
+ }
+ if cfg.RootUserAccess == "" {
+ return fmt.Errorf("root access key is required for IAM API authentication")
+ }
+ if cfg.RootUserSecret == "" {
+ return fmt.Errorf("root secret key is required for IAM API authentication")
+ }
+
+ store, err := storage.New(storage.Config{
+ Dir: cfg.IAMDir,
+ Vault: storage.VaultConfig{
+ EndpointURL: cfg.VaultEndpointURL,
+ Namespace: cfg.VaultNamespace,
+ SecretStoragePath: cfg.VaultSecretStoragePath,
+ SecretStorageNamespace: cfg.VaultSecretStorageNamespace,
+ AuthMethod: cfg.VaultAuthMethod,
+ AuthNamespace: cfg.VaultAuthNamespace,
+ MountPath: cfg.VaultMountPath,
+ RootToken: cfg.VaultRootToken,
+ RoleID: cfg.VaultRoleID,
+ RoleSecret: cfg.VaultRoleSecret,
+ ServerCert: cfg.VaultServerCert,
+ ClientCert: cfg.VaultClientCert,
+ ClientCertKey: cfg.VaultClientCertKey,
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ opts := []iamapi.Option{
+ iamapi.WithConcurrencyLimiter(cfg.MaxConnections, cfg.MaxRequests),
+ iamapi.WithRootUserCreds(iamapi.RootCredentials{
+ Access: cfg.RootUserAccess,
+ Secret: cfg.RootUserSecret,
+ }),
+ }
+ if cfg.HealthPath != "" {
+ opts = append(opts, iamapi.WithHealth(cfg.HealthPath))
+ }
+ if cfg.KeepAlive {
+ opts = append(opts, iamapi.WithKeepAlive())
+ }
+ if cfg.Quiet {
+ opts = append(opts, iamapi.WithQuiet())
+ }
+ if cfg.Debug {
+ debuglogger.SetDebugEnabled()
+ }
+ if cfg.SocketPerm != "" {
+ perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32)
+ if err != nil {
+ return fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err)
+ }
+ opts = append(opts, iamapi.WithSocketPerm(os.FileMode(perm)))
+ }
+ if cfg.CertFile != "" || cfg.KeyFile != "" {
+ if cfg.CertFile == "" {
+ return fmt.Errorf("TLS key specified without cert file")
+ }
+ if cfg.KeyFile == "" {
+ return fmt.Errorf("TLS cert specified without key file")
+ }
+ cs := iamapi.NewCertStorage()
+ if err := cs.SetCertificate(cfg.CertFile, cfg.KeyFile); err != nil {
+ return fmt.Errorf("tls: load certs: %v", err)
+ }
+ opts = append(opts, iamapi.WithTLS(cs))
+ }
+
+ server, err := iamapi.New(store, opts...)
+ if err != nil {
+ return fmt.Errorf("init IAM API server: %w", err)
+ }
+
+ if !cfg.Quiet {
+ cfg.printBanner()
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- server.ServeMultiPort(cfg.Ports)
+ }()
+
+ var sigHup <-chan struct{}
+ if cfg.SigHup != nil {
+ sigHup = cfg.SigHup
+ } else {
+ sigHup = make(chan struct{})
+ }
+
+Loop:
+ for {
+ select {
+ case <-ctx.Done():
+ break Loop
+ case err = <-errCh:
+ break Loop
+ case <-sigHup:
+ if cfg.CertFile != "" && cfg.KeyFile != "" && server.CertStorage != nil {
+ reloadErr := server.CertStorage.SetCertificate(cfg.CertFile, cfg.KeyFile)
+ if reloadErr != nil {
+ debuglogger.InternalError(fmt.Errorf("iam api cert reload failed: %w", reloadErr))
+ } else {
+ fmt.Printf("iam api cert reloaded (cert: %s, key: %s)\n", cfg.CertFile, cfg.KeyFile)
+ }
+ }
+ }
+ }
+ saveErr := err
+
+ if err := server.Shutdown(); err != nil {
+ fmt.Fprintf(os.Stderr, "shutdown IAM API server: %v\n", err)
+ }
+
+ return saveErr
+}
+
+func (cfg IAMConfig) printBanner() {
+ if len(cfg.Ports) == 0 {
+ fmt.Fprintf(os.Stderr, "No ports specified\n")
+ return
+ }
+
+ allInterfaces, allPorts := resolveIAMBannerInterfaces(cfg.Ports)
+ if len(allInterfaces) == 0 {
+ fmt.Fprintf(os.Stderr, "Failed to resolve any listening addresses\n")
+ return
+ }
+
+ versionStr := fmt.Sprintf("Version %v, Build %v", cfg.Version, cfg.Build)
+ if cfg.BuildTime != "" {
+ versionStr += fmt.Sprintf(", BuildTime %v", cfg.BuildTime)
+ }
+
+ lines := []string{
+ centerText(iamTitle),
+ centerText(versionStr),
+ centerText(formatIAMBannerBoundHost(cfg.Ports, allPorts)),
+ centerText(""),
+ leftText("IAM API service listening on:"),
+ }
+
+ for _, u := range buildIAMBannerURLs(allInterfaces, cfg.CertFile != "" || cfg.KeyFile != "") {
+ lines = append(lines, leftText(" "+u))
+ }
+
+ fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
+ for _, line := range lines {
+ fmt.Printf("│%-*s│\n", columnWidth-2, line)
+ }
+ fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘")
+}
+
+func resolveIAMBannerInterfaces(ports []string) ([]string, []string) {
+ var allInterfaces []string
+ var allPorts []string
+ interfaceMap := make(map[string]bool)
+
+ for _, portSpec := range ports {
+ if utils.IsUnixSocketPath(portSpec) {
+ allPorts = append(allPorts, portSpec)
+ if !interfaceMap[portSpec] {
+ interfaceMap[portSpec] = true
+ allInterfaces = append(allInterfaces, portSpec)
+ }
+ continue
+ }
+
+ interfaces, err := getMatchingIPs(portSpec)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to match local IP addresses for %s: %v\n", portSpec, err)
+ continue
+ }
+ _, prt, err := net.SplitHostPort(portSpec)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to parse port %s: %v\n", portSpec, err)
+ continue
+ }
+ allPorts = append(allPorts, prt)
+
+ for _, ip := range interfaces {
+ key := net.JoinHostPort(ip, prt)
+ if !interfaceMap[key] {
+ interfaceMap[key] = true
+ allInterfaces = append(allInterfaces, key)
+ }
+ }
+ }
+
+ return allInterfaces, allPorts
+}
+
+func formatIAMBannerBoundHost(ports, allPorts []string) string {
+ if len(ports) == 1 {
+ if utils.IsUnixSocketPath(ports[0]) {
+ return fmt.Sprintf("(unix socket: %s)", ports[0])
+ }
+ hst, prt, _ := net.SplitHostPort(ports[0])
+ if hst == "" {
+ hst = "0.0.0.0"
+ }
+ return fmt.Sprintf("(bound on host %s and port %s)", hst, prt)
+ }
+
+ return fmt.Sprintf("(bound on ports: %s)", strings.Join(allPorts, ", "))
+}
+
+func buildIAMBannerURLs(interfaces []string, tls bool) []string {
+ var urls []string
+ scheme := "http"
+ if tls {
+ scheme = "https"
+ }
+
+ for _, addrPort := range interfaces {
+ if utils.IsUnixSocketPath(addrPort) {
+ urls = append(urls, "unix:"+addrPort)
+ continue
+ }
+
+ ip, prt, err := net.SplitHostPort(addrPort)
+ if err != nil {
+ continue
+ }
+ urls = append(urls, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip, prt)))
+ }
+
+ return urls
+}
diff --git a/embedgw/iam_test.go b/embedgw/iam_test.go
new file mode 100644
index 00000000..3d92b06b
--- /dev/null
+++ b/embedgw/iam_test.go
@@ -0,0 +1,138 @@
+// 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 embedgw
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestRunIAMAPIValidatesConfig(t *testing.T) {
+ base := IAMConfig{
+ RootUserAccess: "root",
+ RootUserSecret: "secret",
+ Ports: []string{"127.0.0.1:0"},
+ MaxConnections: 1,
+ MaxRequests: 1,
+ IAMDir: t.TempDir(),
+ Quiet: true,
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*IAMConfig)
+ wantErr string
+ }{
+ {
+ name: "missing root access",
+ mutate: func(cfg *IAMConfig) {
+ cfg.RootUserAccess = ""
+ },
+ wantErr: "root access key is required",
+ },
+ {
+ name: "missing root secret",
+ mutate: func(cfg *IAMConfig) {
+ cfg.RootUserSecret = ""
+ },
+ wantErr: "root secret key is required",
+ },
+ {
+ name: "missing ports",
+ mutate: func(cfg *IAMConfig) {
+ cfg.Ports = nil
+ },
+ wantErr: "no ports specified",
+ },
+ {
+ name: "invalid max connections",
+ mutate: func(cfg *IAMConfig) {
+ cfg.MaxConnections = 0
+ },
+ wantErr: "max-connections must be positive",
+ },
+ {
+ name: "invalid max requests",
+ mutate: func(cfg *IAMConfig) {
+ cfg.MaxRequests = 0
+ },
+ wantErr: "max-requests must be positive",
+ },
+ {
+ name: "missing storer",
+ mutate: func(cfg *IAMConfig) {
+ cfg.IAMDir = ""
+ },
+ wantErr: "no IAM storer config specified",
+ },
+ {
+ name: "invalid socket permission",
+ mutate: func(cfg *IAMConfig) {
+ cfg.SocketPerm = "nope"
+ },
+ wantErr: "invalid SocketPerm value",
+ },
+ {
+ name: "missing tls cert",
+ mutate: func(cfg *IAMConfig) {
+ cfg.CertFile = ""
+ cfg.KeyFile = "server.key"
+ },
+ wantErr: "TLS key specified without cert file",
+ },
+ {
+ name: "missing tls key",
+ mutate: func(cfg *IAMConfig) {
+ cfg.CertFile = "server.crt"
+ cfg.KeyFile = ""
+ },
+ wantErr: "TLS cert specified without key file",
+ },
+ {
+ name: "multiple storers",
+ mutate: func(cfg *IAMConfig) {
+ cfg.VaultEndpointURL = "https://vault.example.com"
+ },
+ wantErr: "multiple IAM storer configs specified",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := base
+ cfg.IAMDir = t.TempDir()
+ tt.mutate(&cfg)
+
+ err := RunIAMAPI(context.Background(), &cfg)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("error = %q, want substring %q", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestRunIAMAPIRejectsNilConfig(t *testing.T) {
+ err := RunIAMAPI(context.Background(), nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "iam config is required") {
+ t.Fatalf("error = %q", err)
+ }
+}
diff --git a/go.mod b/go.mod
index 185eabc8..ca9de513 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.26
github.com/aws/aws-sdk-go-v2/credentials v1.19.25
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.12
+ github.com/aws/aws-sdk-go-v2/service/iam v1.54.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1
github.com/aws/smithy-go v1.27.3
github.com/cespare/xxhash/v2 v2.3.0
diff --git a/go.sum b/go.sum
index 398c48d9..b25b1fc8 100644
--- a/go.sum
+++ b/go.sum
@@ -43,6 +43,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJa
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
+github.com/aws/aws-sdk-go-v2/service/iam v1.54.5 h1:a/gAOhIOi+vHYeRU224WIXlJrLXs4Z1Qbm92vfX64jc=
+github.com/aws/aws-sdk-go-v2/service/iam v1.54.5/go.mod h1:tMNzI+fYFCk4cIdZ7FEybLzShwnmWkfxQw85ED1b4ng=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
diff --git a/iamapi/authentication_test.go b/iamapi/authentication_test.go
new file mode 100644
index 00000000..fa3031cf
--- /dev/null
+++ b/iamapi/authentication_test.go
@@ -0,0 +1,626 @@
+// 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 iamapi
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/xml"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+ "github.com/gofiber/fiber/v3"
+ vgwv4 "github.com/versity/versitygw/aws/signer/v4"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+ "github.com/versity/versitygw/internal/sigv4auth"
+)
+
+var testRoot = RootCredentials{
+ Access: "AKID",
+ Secret: "SECRET",
+}
+
+func TestVerifyIAMAuthAcceptsSignedGet(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, readBody(t, resp))
+ }
+}
+
+func TestVerifyIAMAuthAcceptsSignedPost(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ body := []byte("Action=CreateUser&UserName=test-user&Version=2010-05-08")
+ req := signedIAMRequest(t, http.MethodPost, "http://example.com/", body, testRoot.Secret)
+ req.Header.Set("Content-Type", fiber.MIMEApplicationForm)
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, readBody(t, resp))
+ }
+}
+
+func TestVerifyIAMAuthAcceptsUnsignedNonHostHeaders(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ req.Header.Set("Content-Type", "text/plain")
+ req.Header.Set("X-Amz-Copy-Source", "source-bucket/source-key")
+ req.Header.Set("X-Amz-Content-Sha256", "invalid_sha256")
+ req.Header.Set("X-Amz-Tagging", "key=value")
+ req.Header.Set("X-Custom-Header", "value")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, readBody(t, resp))
+ }
+}
+
+func TestVerifyIAMAuthRejectsUnsignedHost(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ authorization := req.Header.Get("Authorization")
+ withoutHost := strings.Replace(authorization, "SignedHeaders=host;", "SignedHeaders=", 1)
+ if withoutHost == authorization {
+ t.Fatalf("Authorization does not contain a signed host header: %q", authorization)
+ }
+ req.Header.Set("Authorization", withoutHost)
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrMissingHostSignedHeader)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthAcceptsQuerySignedGet(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, readBody(t, resp))
+ }
+}
+
+func TestVerifyIAMAuthRejectsMissingQueryParameters(t *testing.T) {
+ testCases := []struct {
+ name string
+ parameter string
+ want iamerr.Error
+ }{
+ {
+ name: "algorithm",
+ parameter: sigv4auth.QueryAlgorithm,
+ want: iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken),
+ },
+ {
+ name: "credential",
+ parameter: sigv4auth.QueryCredential,
+ want: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QueryCredential),
+ },
+ {
+ name: "date",
+ parameter: sigv4auth.QueryDate,
+ want: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QueryDate),
+ },
+ {
+ name: "signed headers",
+ parameter: sigv4auth.QuerySignedHeaders,
+ want: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QuerySignedHeaders),
+ },
+ {
+ name: "signature",
+ parameter: sigv4auth.QuerySignature,
+ want: iamerr.IncompleteSignatureMissingQueryParameter(sigv4auth.QuerySignature),
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ query := req.URL.Query()
+ query.Del(testCase.parameter)
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := testCase.want
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+ })
+ }
+}
+
+func TestVerifyIAMAuthRejectsUnsupportedQueryAlgorithm(t *testing.T) {
+ for _, algorithm := range []string{"AWS4-SHA256", sigv4auth.AlgorithmECDSAP256SHA256} {
+ t.Run(algorithm, func(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ query := req.URL.Query()
+ query.Set(sigv4auth.QueryAlgorithm, algorithm)
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrUnsupportedQueryAlgorithm)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+ })
+ }
+}
+
+func TestVerifyIAMAuthRejectsInvalidQueryDate(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ const invalidDate = "03032006"
+ query := req.URL.Query()
+ query.Set(sigv4auth.QueryDate, invalidDate)
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.IncompleteSignatureInvalidXAmzDate(invalidDate)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsQueryCredentialDateMismatch(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ query := req.URL.Query()
+ credential := strings.Split(query.Get(sigv4auth.QueryCredential), "/")
+ credential[1] = "20000101"
+ query.Set(sigv4auth.QueryCredential, strings.Join(credential, "/"))
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthQueryCredentialParsingMatchesHeaderAuth(t *testing.T) {
+ testCases := []struct {
+ name string
+ credential string
+ want iamerr.Error
+ }{
+ {
+ name: "malformed",
+ credential: "access/hello/world",
+ want: iamerr.IncompleteSignatureMalformedCredential("access/hello/world"),
+ },
+ {
+ name: "invalid terminal",
+ credential: "access/20260627/us-east-1/iam/aws_request",
+ want: iamerr.GetAPIError(iamerr.ErrInvalidTerminal),
+ },
+ {
+ name: "incorrect service",
+ credential: "access/20260627/us-east-1/ec2/aws4_request",
+ want: iamerr.GetAPIError(iamerr.ErrIncorrectService),
+ },
+ {
+ name: "invalid credential date",
+ credential: "access/3223423234/us-east-1/iam/aws4_request",
+ want: iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate),
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ query := req.URL.Query()
+ query.Set(sigv4auth.QueryCredential, testCase.credential)
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := testCase.want
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+ })
+ }
+}
+
+func TestVerifyIAMAuthRejectsQuerySignatureMismatch(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret+"-wrong", iammiddleware.SigningRegion, time.Now().UTC())
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.")
+}
+
+func TestVerifyIAMAuthRejectsUnsignedQueryParameter(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ query := req.URL.Query()
+ query.Set("ExtraParam", "value")
+ req.URL.RawQuery = query.Encode()
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrSignatureDoesNotMatch)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, "us-west-2", time.Now().UTC())
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to a valid region. ")
+}
+
+func TestVerifyIAMAuthRejectsMissingAuthorization(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?Action=ListUsers&Version=2010-05-08", nil))
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "MissingAuthenticationToken", "Request is missing Authentication Token")
+}
+
+func TestVerifyIAMAuthRejectsUnrecognizedAuthorizationHeaders(t *testing.T) {
+ testCases := []struct {
+ name string
+ authorization string
+ }{
+ {
+ name: "invalid header",
+ authorization: "invalid_header",
+ },
+ {
+ name: "unsupported signature version",
+ authorization: "AWS2-HMAC-SHA1 Credential=AKID/20260701/us-east-1/iam/aws4_request,SignedHeaders=host;x-amz-date,Signature=signature",
+ },
+ {
+ name: "ECDSA algorithm",
+ authorization: sigv4auth.AlgorithmECDSAP256SHA256 + " Credential=AKID/20260701/us-east-1/iam/aws4_request,SignedHeaders=host;x-amz-date,Signature=signature",
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ req.Header.Set("Authorization", testCase.authorization)
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+ })
+ }
+}
+
+func TestVerifyIAMAuthRejectsMalformedAuthorizationComponent(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ const component = "SignedHeaders-Content-Length"
+ req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKID/20260701/us-east-1/iam/aws4_request,"+component+",Signature=signature")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.IncompleteSignatureMalformedComponent(component)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsMissingDate(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ authorization := req.Header.Get("Authorization")
+ req.Header.Del("X-Amz-Date")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.IncompleteSignatureMissingDate(authorization)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsInvalidDate(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ const invalidDate = "03032006"
+ req.Header.Set("X-Amz-Date", invalidDate)
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.IncompleteSignatureInvalidXAmzDate(invalidDate)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsSignatureMismatch(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret+"-wrong")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.")
+}
+
+func TestVerifyIAMAuthRejectsWrongCredentialRegion(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequestWithRegion(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, "us-west-2")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to a valid region. ")
+}
+
+func TestVerifyIAMAuthRejectsCredentialDateMismatch(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ authorization := req.Header.Get("Authorization")
+ regExp := regexp.MustCompile("Credential=[^,]+,")
+ req.Header.Set("Authorization", regExp.ReplaceAllString(authorization, "Credential=access/20000101/us-east-1/iam/aws4_request,"))
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestVerifyIAMAuthRejectsMalformedCredential(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ const credential = "access/32234/us-east-1/iam/extra/things"
+ authHdr := req.Header.Get("Authorization")
+ regExp := regexp.MustCompile("Credential=[^,]+,")
+ req.Header.Set("Authorization", regExp.ReplaceAllString(authHdr, "Credential="+credential+","))
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ requireIAMError(t, resp, http.StatusBadRequest, "Sender", "IncompleteSignature", "Credential must have exactly 5 slash-delimited elements, e.g. keyid/date/region/service/term, got '"+credential+"'")
+}
+
+func TestVerifyIAMAuthRejectsInvalidCredentialTerminal(t *testing.T) {
+ app := newIAMAuthTestApp(t)
+ req := signedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret)
+ authHdr := req.Header.Get("Authorization")
+ regExp := regexp.MustCompile("Credential=[^,]+,")
+ req.Header.Set("Authorization", regExp.ReplaceAllString(authHdr, "Credential=access/32234/us-east-1/iam/aws_request,"))
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+
+ want := iamerr.GetAPIError(iamerr.ErrInvalidTerminal)
+ requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message)
+}
+
+func TestValidateDateAtRejectsFutureDate(t *testing.T) {
+ serverTime := time.Date(2026, time.June, 27, 20, 18, 14, 0, time.UTC)
+ requestTime := time.Date(2026, time.July, 2, 20, 18, 13, 0, time.UTC)
+
+ err := iammiddleware.ValidateDateAt(requestTime, serverTime)
+ want := iamerr.SignatureDoesNotMatchNotYetCurrent(requestTime, serverTime, 15*time.Minute)
+ if err != want {
+ t.Fatalf("ValidateDateAt() error = %#v, want %#v", err, want)
+ }
+}
+
+func TestValidateDateAtRejectsPastDate(t *testing.T) {
+ serverTime := time.Date(2026, time.June, 27, 20, 19, 55, 0, time.UTC)
+ requestTime := time.Date(2026, time.June, 22, 20, 19, 54, 0, time.UTC)
+
+ err := iammiddleware.ValidateDateAt(requestTime, serverTime)
+ want := iamerr.SignatureDoesNotMatchExpired(requestTime, serverTime, 15*time.Minute)
+ if err != want {
+ t.Fatalf("ValidateDateAt() error = %#v, want %#v", err, want)
+ }
+}
+
+func newIAMAuthTestApp(t *testing.T) *fiber.App {
+ t.Helper()
+
+ app := fiber.New(fiber.Config{ErrorHandler: iammiddleware.GlobalErrorHandler})
+ app.All("/", ProcessHandlers(
+ func(ctx fiber.Ctx) (*Response, error) {
+ return &Response{Status: http.StatusOK}, nil
+ },
+ iammiddleware.VerifyIAMAuth(&testRoot),
+ ))
+ return app
+}
+
+func signedIAMRequest(t *testing.T, method, target string, body []byte, secret string) *http.Request {
+ t.Helper()
+
+ return signedIAMRequestWithRegion(t, method, target, body, secret, iammiddleware.SigningRegion)
+}
+
+func signedIAMRequestWithRegion(t *testing.T, method, target string, body []byte, secret, region string) *http.Request {
+ t.Helper()
+
+ req := httptest.NewRequest(method, target, bytes.NewReader(body))
+ hash := sha256.Sum256(body)
+ payloadHash := hex.EncodeToString(hash[:])
+
+ signer := awsv4.NewSigner()
+ if err := signer.SignHTTP(
+ context.Background(),
+ aws.Credentials{AccessKeyID: testRoot.Access, SecretAccessKey: secret},
+ req,
+ payloadHash,
+ "iam",
+ region,
+ time.Now().UTC(),
+ ); err != nil {
+ t.Fatalf("sign request: %v", err)
+ }
+
+ return req
+}
+
+func querySignedIAMRequest(t *testing.T, method, target string, body []byte, secret, region string, signingTime time.Time) *http.Request {
+ t.Helper()
+
+ req := httptest.NewRequest(method, target, bytes.NewReader(body))
+
+ hash := sha256.Sum256(body)
+ payloadHash := hex.EncodeToString(hash[:])
+
+ signer := vgwv4.NewSigner()
+ signedURL, signedHeaders, _, err := signer.PresignHTTP(
+ context.Background(),
+ aws.Credentials{AccessKeyID: testRoot.Access, SecretAccessKey: secret},
+ req,
+ payloadHash,
+ "iam",
+ region,
+ signingTime,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("presign request: %v", err)
+ }
+
+ signedReq := httptest.NewRequest(method, signedURL, bytes.NewReader(body))
+ for key, values := range signedHeaders {
+ for _, value := range values {
+ signedReq.Header.Add(key, value)
+ }
+ }
+
+ return signedReq
+}
+
+func requireIAMError(t *testing.T, resp *http.Response, status int, errType, code, message string) {
+ t.Helper()
+
+ body := readBody(t, resp)
+ if resp.StatusCode != status {
+ t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, status, body)
+ }
+
+ var errResp struct {
+ XMLName xml.Name `xml:"ErrorResponse"`
+ Error struct {
+ Type string
+ Code string
+ Message string
+ }
+ RequestID string `xml:"RequestId"`
+ }
+ if err := xml.Unmarshal([]byte(body), &errResp); err != nil {
+ t.Fatalf("unmarshal IAM error: %v\n%s", err, body)
+ }
+
+ wantNamespace := iamerr.Namespace
+ if code == "InvalidAction" {
+ wantNamespace = iamerr.AWSFaultNamespace
+ }
+ if errResp.XMLName.Space != wantNamespace {
+ t.Fatalf("namespace = %q, want %q", errResp.XMLName.Space, wantNamespace)
+ }
+ if errResp.Error.Type != errType || errResp.Error.Code != code || errResp.Error.Message != message {
+ t.Fatalf("error = %#v, want type=%q code=%q message=%q", errResp.Error, errType, code, message)
+ }
+ if errResp.RequestID == "" {
+ t.Fatal("missing RequestId")
+ }
+}
+
+func readBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ return string(body)
+}
diff --git a/iamapi/controller.go b/iamapi/controller.go
new file mode 100644
index 00000000..34511a3f
--- /dev/null
+++ b/iamapi/controller.go
@@ -0,0 +1,235 @@
+// 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 iamapi
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/internal/iamutil"
+ "github.com/versity/versitygw/iamapi/storage"
+ "github.com/versity/versitygw/iamapi/types"
+)
+
+type IAMApiController struct {
+ store storage.Storer
+}
+
+func NewController(store storage.Storer) IAMApiController {
+ return IAMApiController{store: store}
+}
+
+func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) {
+ userName, ok := iamutil.RequestParam(ctx, "UserName")
+ if !ok {
+ debuglogger.Logf("missing required CreateUser parameter: UserName")
+ return nil, iamerr.GetAPIError(iamerr.ErrMissingUserNameValue)
+ }
+ if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserNameLen); err != nil {
+ return nil, err
+ }
+
+ path, ok := iamutil.RequestParam(ctx, "Path")
+ if !ok || path == "" {
+ path = iamutil.DefaultUserPath
+ }
+ if err := iamutil.ValidatePath("path", path); err != nil {
+ return nil, err
+ }
+
+ tags, err := iamutil.ParseTags(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ for range 3 {
+ userID, err := iamutil.GenerateUserID()
+ if err != nil {
+ return nil, err
+ }
+
+ user := types.User{
+ Path: path,
+ UserName: userName,
+ UserID: userID,
+ Arn: iamutil.BuildUserArn(iamutil.DefaultAccountID, path, userName),
+ CreateDate: time.Now().UTC().Truncate(time.Second),
+ Tags: tags,
+ }
+
+ stored, err := c.store.CreateUser(ctx.Context(), user)
+ if errors.Is(err, storage.ErrUserIDAlreadyExists) {
+ debuglogger.Logf("IAM user ID collision while creating user %q: %v", userName, err)
+ continue
+ }
+ if err != nil {
+ debuglogger.Logf("failed to create IAM user %q: %v", userName, err)
+ return nil, err
+ }
+
+ return &Response{Data: &types.CreateUserResponse{
+ Result: types.CreateUserResult{User: *stored},
+ }}, nil
+ }
+
+ err = fmt.Errorf("generate IAM user id: exhausted collision retries")
+ debuglogger.Logf("failed to create IAM user %q: %v", userName, err)
+ return nil, err
+}
+
+func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) {
+ username, ok := iamutil.RequestParam(ctx, "UserName")
+ if !ok || username == "" {
+ debuglogger.Logf("missing required DeleteUser parameter: UserName")
+ return nil, iamerr.MissingParameter("UserName")
+ }
+ if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
+ return nil, err
+ }
+
+ if err := c.store.DeleteUser(ctx.Context(), username); err != nil {
+ debuglogger.Logf("failed to delete IAM user %q: %v", username, err)
+ return nil, err
+ }
+
+ return &Response{Data: &types.DeleteUserResponse{}}, nil
+}
+
+func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) {
+ username, ok := iamutil.RequestParam(ctx, "UserName")
+ if !ok {
+ debuglogger.Logf("missing required GetUser parameter: UserName")
+ return nil, iamerr.MissingParameter("UserName")
+ }
+ if username == "" {
+ return &Response{Data: &types.GetUserResponse{
+ Result: types.GetUserResult{User: types.User{
+ UserID: iamutil.DefaultAccountID,
+ Arn: fmt.Sprintf("arn:aws:iam::%s:root", iamutil.DefaultAccountID),
+ }},
+ }}, nil
+ }
+ if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
+ return nil, err
+ }
+
+ user, err := c.store.GetUser(ctx.Context(), username)
+ if err != nil {
+ debuglogger.Logf("failed to get IAM user %q: %v", username, err)
+ return nil, err
+ }
+
+ return &Response{Data: &types.GetUserResponse{
+ Result: types.GetUserResult{User: *user},
+ }}, nil
+}
+
+func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) {
+ pathPrefix, ok := iamutil.RequestParam(ctx, "PathPrefix")
+ if !ok || pathPrefix == "" {
+ pathPrefix = iamutil.DefaultUserPath
+ }
+ if err := iamutil.ValidatePathPrefix(pathPrefix); err != nil {
+ return nil, err
+ }
+
+ maxItems := int32(iamutil.DefaultMaxItems)
+ if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" {
+ parsed, err := strconv.ParseInt(rawMaxItems, 10, 32)
+ if err != nil || parsed < 1 || parsed > iamutil.MaxListItems {
+ debuglogger.Logf("invalid ListUsers MaxItems value %q: parse_error=%v", rawMaxItems, err)
+ return nil, iamerr.InvalidMaxItems(rawMaxItems)
+ }
+ maxItems = int32(parsed)
+ }
+
+ marker, _ := iamutil.RequestParam(ctx, "Marker")
+ out, err := c.store.ListUsers(ctx.Context(), storage.ListUsersInput{
+ PathPrefix: pathPrefix,
+ Marker: marker,
+ MaxItems: maxItems,
+ })
+ if err != nil {
+ debuglogger.Logf("failed to list IAM users: %v", err)
+ return nil, err
+ }
+
+ return &Response{Data: &types.ListUsersResponse{
+ Result: types.ListUsersResult{
+ Users: types.Users{Members: out.Users},
+ IsTruncated: out.IsTruncated,
+ Marker: out.Marker,
+ },
+ }}, nil
+}
+
+func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) {
+ username, ok := iamutil.RequestParam(ctx, "UserName")
+ if !ok || username == "" {
+ debuglogger.Logf("missing required UpdateUser parameter: UserName")
+ return nil, iamerr.MissingParameter("UserName")
+ }
+ if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil {
+ return nil, err
+ }
+
+ newPath, _ := iamutil.RequestParam(ctx, "NewPath")
+ if newPath != "" {
+ if err := iamutil.ValidatePath("newPath", newPath); err != nil {
+ return nil, err
+ }
+ }
+ newUserName, _ := iamutil.RequestParam(ctx, "NewUserName")
+ if newUserName != "" {
+ if err := iamutil.ValidateUserName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil {
+ return nil, err
+ }
+ }
+
+ user, err := c.store.GetUser(ctx.Context(), username)
+ if err != nil {
+ debuglogger.Logf("failed to get IAM user %q for update: %v", username, err)
+ return nil, err
+ }
+
+ finalPath := user.Path
+ if newPath != "" {
+ finalPath = newPath
+ }
+ finalUserName := user.UserName
+ if newUserName != "" {
+ finalUserName = newUserName
+ }
+
+ updated, err := c.store.UpdateUser(ctx.Context(), storage.UpdateUserInput{
+ UserName: username,
+ NewPath: newPath,
+ NewUserName: newUserName,
+ NewArn: iamutil.BuildUserArn(iamutil.DefaultAccountID, finalPath, finalUserName),
+ })
+ if err != nil {
+ debuglogger.Logf("failed to update IAM user %q: %v", finalUserName, err)
+ return nil, err
+ }
+
+ return &Response{Data: &types.UpdateUserResponse{
+ Result: types.UpdateUserResult{User: updated},
+ }}, nil
+}
diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go
new file mode 100644
index 00000000..88b59f45
--- /dev/null
+++ b/iamapi/controller_test.go
@@ -0,0 +1,524 @@
+// 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 iamapi
+
+import (
+ "encoding/xml"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+ "github.com/versity/versitygw/iamapi/internal/iamutil"
+ "github.com/versity/versitygw/iamapi/storage"
+ iamtypes "github.com/versity/versitygw/iamapi/types"
+)
+
+var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`)
+
+func TestIAMApiControllerUserLifecycle(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+
+ create := doIAMAction(t, server, url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Path": {"/engineering/"},
+ "Tags.member.1.Key": {"env"},
+ "Tags.member.1.Value": {"test"},
+ "Tags.member.2.Key": {"empty"},
+ "Tags.member.2.Value": {""},
+ })
+ if create.StatusCode != http.StatusOK {
+ t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
+ }
+ createBody := readBody(t, create)
+ var createOut iamtypes.CreateUserResponse
+ unmarshalXML(t, createBody, &createOut)
+ if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateUserResponse" {
+ t.Fatalf("CreateUser XMLName = %#v", createOut.XMLName)
+ }
+ user := createOut.Result.User
+ if user.Path != "/engineering/" || user.UserName != "alice" {
+ t.Fatalf("created user = %#v, want path/name", user)
+ }
+ if !userIDPattern.MatchString(user.UserID) {
+ t.Fatalf("UserId = %q, want AWS IAM user id form", user.UserID)
+ }
+ if user.Arn != "arn:aws:iam::000000000000:user/engineering/alice" {
+ t.Fatalf("Arn = %q", user.Arn)
+ }
+ if user.CreateDate.IsZero() {
+ t.Fatal("CreateDate is zero")
+ }
+ requireUserTags(t, user.Tags)
+ if createOut.ResponseMetadata.RequestID == "" {
+ t.Fatal("CreateUser missing RequestId")
+ }
+
+ duplicate := doIAMAction(t, server, url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ })
+ requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name alice already exists.")
+
+ update := doIAMAction(t, server, url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"alice"},
+ "NewUserName": {"zoe"},
+ "NewPath": {"/ops/"},
+ })
+ if update.StatusCode != http.StatusOK {
+ t.Fatalf("UpdateUser status = %d, body=%s", update.StatusCode, readBody(t, update))
+ }
+ var updateOut iamtypes.UpdateUserResponse
+ unmarshalXML(t, readBody(t, update), &updateOut)
+ if updateOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || updateOut.XMLName.Local != "UpdateUserResponse" {
+ t.Fatalf("UpdateUser XMLName = %#v", updateOut.XMLName)
+ }
+ if updateOut.ResponseMetadata.RequestID == "" {
+ t.Fatal("UpdateUser missing RequestId")
+ }
+ updatedUser := updateOut.Result.User
+ if updatedUser.UserID != user.UserID || !updatedUser.CreateDate.Equal(user.CreateDate) {
+ t.Fatalf("UpdateUser result identity = %#v, want UserId/CreateDate preserved from %#v", updatedUser, user)
+ }
+ if updatedUser.UserName != "zoe" || updatedUser.Path != "/ops/" ||
+ updatedUser.Arn != "arn:aws:iam::000000000000:user/ops/zoe" {
+ t.Fatalf("UpdateUser result = %#v", updatedUser)
+ }
+
+ get := doIAMAction(t, server, url.Values{
+ "Action": {"GetUser"},
+ "UserName": {"zoe"},
+ })
+ if get.StatusCode != http.StatusOK {
+ t.Fatalf("GetUser status = %d, body=%s", get.StatusCode, readBody(t, get))
+ }
+ var getOut iamtypes.GetUserResponse
+ unmarshalXML(t, readBody(t, get), &getOut)
+ gotUser := getOut.Result.User
+ if gotUser.UserID != user.UserID || !gotUser.CreateDate.Equal(user.CreateDate) {
+ t.Fatalf("updated user identity = %#v, want UserId/CreateDate preserved from %#v", gotUser, user)
+ }
+ if gotUser.Path != "/ops/" || gotUser.UserName != "zoe" ||
+ gotUser.Arn != "arn:aws:iam::000000000000:user/ops/zoe" {
+ t.Fatalf("GetUser after update = %#v", gotUser)
+ }
+ requireUserTags(t, gotUser.Tags)
+
+ list := doIAMAction(t, server, url.Values{
+ "Action": {"ListUsers"},
+ "PathPrefix": {"/ops/"},
+ })
+ if list.StatusCode != http.StatusOK {
+ t.Fatalf("ListUsers status = %d, body=%s", list.StatusCode, readBody(t, list))
+ }
+ var listOut iamtypes.ListUsersResponse
+ unmarshalXML(t, readBody(t, list), &listOut)
+ if len(listOut.Result.Users.Members) != 1 || listOut.Result.Users.Members[0].UserName != "zoe" {
+ t.Fatalf("ListUsers = %#v, want zoe", listOut.Result.Users.Members)
+ }
+ requireUserTags(t, listOut.Result.Users.Members[0].Tags)
+
+ deleteResp := doIAMAction(t, server, url.Values{
+ "Action": {"DeleteUser"},
+ "UserName": {"zoe"},
+ })
+ if deleteResp.StatusCode != http.StatusOK {
+ t.Fatalf("DeleteUser status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp))
+ }
+ var deleteOut iamtypes.DeleteUserResponse
+ unmarshalXML(t, readBody(t, deleteResp), &deleteOut)
+ if deleteOut.XMLName.Local != "DeleteUserResponse" || deleteOut.ResponseMetadata.RequestID == "" {
+ t.Fatalf("DeleteUser output = %#v", deleteOut)
+ }
+
+ missing := doIAMAction(t, server, url.Values{
+ "Action": {"GetUser"},
+ "UserName": {"zoe"},
+ })
+ requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user with name zoe cannot be found.")
+}
+
+func TestIAMApiControllerGetRootUser(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ resp := doIAMAction(t, server, url.Values{
+ "Action": {"GetUser"},
+ "UserName": {""},
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GetUser root status = %d, body=%s", resp.StatusCode, readBody(t, resp))
+ }
+
+ var out iamtypes.GetUserResponse
+ unmarshalXML(t, readBody(t, resp), &out)
+ if out.Result.User.UserID != iamutil.DefaultAccountID {
+ t.Fatalf("GetUser root UserId = %q, want %q", out.Result.User.UserID, iamutil.DefaultAccountID)
+ }
+ if out.Result.User.Arn != "arn:aws:iam::000000000000:root" {
+ t.Fatalf("GetUser root Arn = %q", out.Result.User.Arn)
+ }
+ if out.ResponseMetadata.RequestID == "" {
+ t.Fatal("GetUser root missing RequestId")
+ }
+
+ missing := doIAMAction(t, server, url.Values{"Action": {"GetUser"}})
+ requireIAMError(t, missing, http.StatusBadRequest, "Sender", "MissingParameter", "The request must contain the parameter UserName.")
+}
+
+func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ params url.Values
+ status int
+ code string
+ message string
+ }{
+ {
+ name: "missing username",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
+ },
+ {
+ name: "invalid path",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Path": {"bad"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.",
+ },
+ {
+ name: "long path",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Path": {"/" + strings.Repeat("a", 511) + "/"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'path' failed to satisfy constraint: Member must have length less than or equal to 512",
+ },
+ {
+ name: "invalid username",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"bad/name"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
+ },
+ {
+ name: "long username",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {strings.Repeat("a", 65)},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 64",
+ },
+ {
+ name: "invalid tag key",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {"bad*key"},
+ "Tags.member.1.Value": {"test"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "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}_.:/=+\\-@]+",
+ },
+ {
+ name: "long tag key",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {strings.Repeat("k", 129)},
+ "Tags.member.1.Value": {"test"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128",
+ },
+ {
+ name: "invalid tag value",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {"badval"},
+ "Tags.member.1.Value": {"bad*value"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "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}_.:/=+\\-@]*",
+ },
+ {
+ name: "long tag value",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {"key"},
+ "Tags.member.1.Value": {strings.Repeat("v", 257)},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256",
+ },
+ {
+ name: "duplicate tag key",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {"dup"},
+ "Tags.member.1.Value": {"one"},
+ "Tags.member.2.Key": {"DUP"},
+ "Tags.member.2.Value": {"two"},
+ },
+ status: http.StatusBadRequest,
+ code: "InvalidInput",
+ message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
+ },
+ {
+ name: "missing tag key",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Value": {"test"},
+ },
+ status: http.StatusBadRequest,
+ code: "MissingParameter",
+ message: "The request must contain the parameter Tags.member.1.Key.",
+ },
+ {
+ name: "missing tag value",
+ params: url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {"alice"},
+ "Tags.member.1.Key": {"env"},
+ },
+ status: http.StatusBadRequest,
+ code: "MissingParameter",
+ message: "The request must contain the parameter Tags.member.1.Value.",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ resp := doIAMAction(t, server, tt.params)
+ requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
+ })
+ }
+}
+
+func TestIAMApiControllerDeleteAndUpdateUserErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ params url.Values
+ status int
+ code string
+ message string
+ }{
+ {
+ name: "delete invalid username",
+ params: url.Values{
+ "Action": {"DeleteUser"},
+ "UserName": {"bad/name"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
+ },
+ {
+ name: "delete long username",
+ params: url.Values{
+ "Action": {"DeleteUser"},
+ "UserName": {strings.Repeat("a", 129)},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 128",
+ },
+ {
+ name: "delete missing user",
+ params: url.Values{
+ "Action": {"DeleteUser"},
+ "UserName": {"asdfadsf"},
+ },
+ status: http.StatusNotFound,
+ code: "NoSuchEntity",
+ message: "The user with name asdfadsf cannot be found.",
+ },
+ {
+ name: "update invalid username",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"bad/name"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
+ },
+ {
+ name: "update long username",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {strings.Repeat("a", 129)},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 128",
+ },
+ {
+ name: "update invalid new username",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"asdfadsf"},
+ "NewUserName": {"bad/name"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for newUserName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
+ },
+ {
+ name: "update long new username",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"asdfadsf"},
+ "NewUserName": {strings.Repeat("a", 65)},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'newUserName' failed to satisfy constraint: Member must have length less than or equal to 64",
+ },
+ {
+ name: "update invalid new path",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"asdfadsf"},
+ "NewPath": {"invalid"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "The specified value for newPath is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.",
+ },
+ {
+ name: "update long new path",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"asdfadsf"},
+ "NewPath": {"/" + strings.Repeat("a", 511) + "/"},
+ },
+ status: http.StatusBadRequest,
+ code: "ValidationError",
+ message: "1 validation error detected: Value at 'newPath' failed to satisfy constraint: Member must have length less than or equal to 512",
+ },
+ {
+ name: "update missing user",
+ params: url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"asdfadsf"},
+ },
+ status: http.StatusNotFound,
+ code: "NoSuchEntity",
+ message: "The user with name asdfadsf cannot be found.",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ resp := doIAMAction(t, server, tt.params)
+ requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
+ })
+ }
+}
+
+func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ for _, userName := range []string{"alice", "zoe"} {
+ resp := doIAMAction(t, server, url.Values{
+ "Action": {"CreateUser"},
+ "UserName": {userName},
+ })
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("CreateUser(%q) status = %d, body=%s", userName, resp.StatusCode, readBody(t, resp))
+ }
+ resp.Body.Close()
+ }
+
+ resp := doIAMAction(t, server, url.Values{
+ "Action": {"UpdateUser"},
+ "UserName": {"alice"},
+ "NewUserName": {"zoe"},
+ })
+ requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.")
+}
+
+func newIAMControllerTestServer(t *testing.T) *IAMApiServer {
+ t.Helper()
+
+ store, err := storage.New(storage.Config{Dir: t.TempDir()})
+ if err != nil {
+ t.Fatalf("storage.New: %v", err)
+ }
+ server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return server
+}
+
+func doIAMAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Response {
+ t.Helper()
+ if !params.Has("Version") {
+ params.Set("Version", iamAPIVersion)
+ }
+
+ req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?"+params.Encode(), nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC())
+ resp, err := server.app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ return resp
+}
+
+func unmarshalXML(t *testing.T, body string, out any) {
+ t.Helper()
+
+ if err := xml.Unmarshal([]byte(body), out); err != nil {
+ t.Fatalf("unmarshal XML: %v\n%s", err, body)
+ }
+}
+
+func requireUserTags(t *testing.T, tags []iamtypes.Tag) {
+ t.Helper()
+
+ if len(tags) != 2 || tags[0].Key != "env" || tags[0].Value != "test" ||
+ tags[1].Key != "empty" || tags[1].Value != "" {
+ t.Fatalf("Tags = %#v, want env=test and empty=", tags)
+ }
+}
diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go
new file mode 100644
index 00000000..28f52871
--- /dev/null
+++ b/iamapi/iamerr/errors.go
@@ -0,0 +1,393 @@
+// 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 iamerr
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const (
+ Namespace = "https://iam.amazonaws.com/doc/2010-05-08/"
+ AWSFaultNamespace = "http://webservices.amazon.com/AWSFault/2005-15-09"
+)
+
+type ErrorType string
+
+const (
+ TypeSender ErrorType = "Sender"
+ TypeReceiver ErrorType = "Receiver"
+)
+
+type ErrorCode int
+
+const (
+ ErrInternalFailure ErrorCode = iota
+
+ ErrSignatureDoesNotMatch
+ ErrMissingAuthenticationToken
+ ErrIncompleteSignature
+ ErrUnsupportedSignatureVersion
+ ErrMissingAuthorizationComponents
+ ErrIncorrectService
+ ErrInvalidCredentialDate
+ ErrInvalidTerminal
+ ErrUnsupportedQueryAlgorithm
+ ErrInvalidRegion
+ ErrMissingHostSignedHeader
+ ErrInvalidClientTokenID
+
+ ErrInvalidContentLength
+ ErrThrottling
+
+ ErrMissingUserNameValue
+ ErrTooManyTags
+ ErrInvalidPathPrefix
+ ErrDuplicateTagKeys
+)
+
+type APIError interface {
+ error
+ StatusCode() int
+ XMLBody(requestID string) []byte
+}
+
+type Error struct {
+ Type ErrorType
+ Code string
+ Message string
+ HTTPStatusCode int
+ XMLNamespace string
+}
+
+func (e Error) Error() string {
+ return e.Code + ": " + e.Message
+}
+
+func (e Error) StatusCode() int {
+ return e.HTTPStatusCode
+}
+
+func (e Error) XMLBody(requestID string) []byte {
+ namespace := e.XMLNamespace
+ if namespace == "" {
+ namespace = Namespace
+ }
+
+ body, err := xml.Marshal(struct {
+ XMLName xml.Name
+ Error errorXML
+ RequestID string `xml:"RequestId"`
+ }{
+ XMLName: xml.Name{Space: namespace, Local: "ErrorResponse"},
+ Error: errorXML{
+ Type: e.Type,
+ Code: e.Code,
+ Message: e.Message,
+ },
+ RequestID: requestID,
+ })
+ if err != nil {
+ return nil
+ }
+
+ return append([]byte(xml.Header), body...)
+}
+
+type errorXML struct {
+ Type ErrorType
+ Code string
+ Message string
+}
+
+var errorCodeResponse = map[ErrorCode]Error{
+ ErrInternalFailure: {
+ Type: TypeReceiver,
+ Code: "InternalFailure",
+ Message: "The request processing has failed because of an unknown error, exception or failure.",
+ HTTPStatusCode: http.StatusInternalServerError,
+ },
+
+ ErrInvalidContentLength: {
+ Type: TypeSender,
+ Code: "InvalidRequest",
+ Message: "Content-Length must be a valid integer.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrThrottling: {
+ Type: TypeSender,
+ Code: "Throttling",
+ Message: "Rate exceeded.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+
+ ErrMissingAuthenticationToken: {
+ Type: TypeSender,
+ Code: "MissingAuthenticationToken",
+ Message: "Request is missing Authentication Token",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+ ErrUnsupportedQueryAlgorithm: {
+ Type: TypeSender,
+ Code: "MissingAuthenticationToken",
+ Message: "Missing Authentication Token",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+ ErrInvalidClientTokenID: {
+ Type: TypeSender,
+ Code: "InvalidClientTokenId",
+ Message: "The security token included in the request is invalid.",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+
+ ErrIncompleteSignature: {
+ Type: TypeSender,
+ Code: "IncompleteSignature",
+ Message: "The request signature does not conform to AWS standards.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrUnsupportedSignatureVersion: {
+ Type: TypeSender,
+ Code: "IncompleteSignature",
+ Message: "AWS Signature Version 2 is not supported.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrMissingAuthorizationComponents: {
+ Type: TypeSender,
+ Code: "IncompleteSignature",
+ Message: "Authorization header requires Credential, SignedHeaders, and Signature.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+
+ ErrSignatureDoesNotMatch: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+ ErrIncorrectService: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "Credential should be scoped to correct service: 'iam'.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrInvalidCredentialDate: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrInvalidTerminal: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "Credential should be scoped with a valid terminator: 'aws4_request'.",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+ ErrInvalidRegion: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "Credential should be scoped to a valid region. ",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+ ErrMissingHostSignedHeader: {
+ Type: TypeSender,
+ Code: "SignatureDoesNotMatch",
+ Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.",
+ HTTPStatusCode: http.StatusForbidden,
+ },
+
+ ErrMissingUserNameValue: {
+ Type: TypeSender,
+ Code: "ValidationError",
+ Message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrInvalidPathPrefix: {
+ Type: TypeSender,
+ Code: "ValidationError",
+ Message: "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrTooManyTags: {
+ Type: TypeSender,
+ Code: "ValidationError",
+ Message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+ ErrDuplicateTagKeys: {
+ Type: TypeSender,
+ Code: "InvalidInput",
+ Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
+ HTTPStatusCode: http.StatusBadRequest,
+ },
+}
+
+func GetAPIError(code ErrorCode) Error {
+ if err, ok := errorCodeResponse[code]; ok {
+ return err
+ }
+
+ return errorCodeResponse[ErrInternalFailure]
+}
+
+func InvalidAction(action, version string) Error {
+ err := newSenderError("InvalidAction", fmt.Sprintf("Could not find operation %s for version %s", action, version), http.StatusBadRequest)
+ err.XMLNamespace = AWSFaultNamespace
+ return err
+}
+
+func MissingParameter(parameter string) Error {
+ return newSenderError("MissingParameter", fmt.Sprintf("The request must contain the parameter %s.", parameter), http.StatusBadRequest)
+}
+
+func IncompleteSignatureMalformedComponent(component string) Error {
+ err := GetAPIError(ErrIncompleteSignature)
+ err.Message = fmt.Sprintf("Authorization component %q is malformed.", component)
+ return err
+}
+
+func IncompleteSignatureMalformedCredential(credential string) Error {
+ return newSenderError(
+ "IncompleteSignature",
+ fmt.Sprintf("Credential must have exactly 5 slash-delimited elements, e.g. keyid/date/region/service/term, got '%s'", credential),
+ http.StatusBadRequest,
+ )
+}
+
+func IncompleteSignatureMissingAuthorizationComponent(component, authorization string) Error {
+ err := GetAPIError(ErrIncompleteSignature)
+ err.Message = fmt.Sprintf("Authorization header requires '%s' parameter. (Hashed with SHA-256 and encoded with Base64) Authorization=%s",
+ component,
+ hashAuthorization(authorization))
+ return err
+}
+
+func IncompleteSignatureMissingQueryParameter(parameter string) Error {
+ err := GetAPIError(ErrIncompleteSignature)
+ err.Message = fmt.Sprintf("AWS query-string parameters must include '%s'. Re-examine the query-string parameters.", parameter)
+ return err
+}
+
+func IncompleteSignatureMissingDate(authorization string) Error {
+ return newSenderError(
+ "IncompleteSignature",
+ fmt.Sprintf("Authorization header requires existence of either a 'X-Amz-Date' or a 'Date' header. (Hashed with SHA-256 and encoded with Base64) Authorization=%s", hashAuthorization(authorization)),
+ http.StatusBadRequest,
+ )
+}
+
+func IncompleteSignatureInvalidXAmzDate(date string) Error {
+ return newSenderError(
+ "IncompleteSignature",
+ fmt.Sprintf("Date must be in ISO-8601 'basic format'. Got '%s'. See http://en.wikipedia.org/wiki/ISO_8601", date),
+ http.StatusBadRequest,
+ )
+}
+
+func IncompleteSignatureHeadersNotSigned(headers []string) Error {
+ err := GetAPIError(ErrIncompleteSignature)
+ err.Message = fmt.Sprintf("The request signature does not conform to AWS standards. Header(s) not signed: %s.", strings.Join(headers, ", "))
+ return err
+}
+
+func SignatureDoesNotMatchNotYetCurrent(requestTime, serverTime time.Time, allowedSkew time.Duration) Error {
+ err := GetAPIError(ErrSignatureDoesNotMatch)
+ err.Message = fmt.Sprintf("Signature not yet current: %s is still later than %s (%s + %d min.)",
+ requestTime.UTC().Format("20060102T150405Z"),
+ serverTime.UTC().Add(allowedSkew).Format("20060102T150405Z"),
+ serverTime.UTC().Format("20060102T150405Z"),
+ allowedSkew/time.Minute)
+ return err
+}
+
+func SignatureDoesNotMatchExpired(requestTime, serverTime time.Time, allowedSkew time.Duration) Error {
+ err := GetAPIError(ErrSignatureDoesNotMatch)
+ err.Message = fmt.Sprintf("Signature expired: %s is now earlier than %s (%s - %d min.)",
+ requestTime.UTC().Format("20060102T150405Z"),
+ serverTime.UTC().Add(-allowedSkew).Format("20060102T150405Z"),
+ serverTime.UTC().Format("20060102T150405Z"),
+ allowedSkew/time.Minute)
+ return err
+}
+
+func EntityAlreadyExistsUser(userName string) Error {
+ return newSenderError("EntityAlreadyExists", fmt.Sprintf("User with name %s already exists.", userName), http.StatusConflict)
+}
+
+func NoSuchEntityUser(userName string) Error {
+ return newSenderError("NoSuchEntity", fmt.Sprintf("The user with name %s cannot be found.", userName), http.StatusNotFound)
+}
+
+func ValidationError(message string) Error {
+ return newSenderError("ValidationError", message, http.StatusBadRequest)
+}
+
+func InvalidInput(message string) Error {
+ return newSenderError("InvalidInput", message, http.StatusBadRequest)
+}
+
+func InvalidUserName(field string) Error {
+ return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", field))
+}
+
+func UserNameTooLong(field string, maxLength int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength))
+}
+
+func InvalidPath(field string) Error {
+ return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.", field))
+}
+
+func PathTooLong(field string, maxLength int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength))
+}
+
+func InvalidMaxItems(value string) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", value))
+}
+
+func TagKeyTooLong(index int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", index))
+}
+
+func InvalidTagKey(index int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+", index))
+}
+
+func TagValueTooLong(index int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", index))
+}
+
+func InvalidTagValue(index int) Error {
+ return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*", index))
+}
+
+func newSenderError(code, message string, statusCode int) Error {
+ return Error{
+ Type: TypeSender,
+ Code: code,
+ Message: message,
+ HTTPStatusCode: statusCode,
+ }
+}
+
+func hashAuthorization(authorization string) string {
+ hash := sha256.Sum256([]byte(authorization))
+ return base64.StdEncoding.EncodeToString(hash[:])
+}
diff --git a/iamapi/internal/iammiddleware/auth.go b/iamapi/internal/iammiddleware/auth.go
new file mode 100644
index 00000000..aaf09fd0
--- /dev/null
+++ b/iamapi/internal/iammiddleware/auth.go
@@ -0,0 +1,258 @@
+// 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 iammiddleware
+
+import (
+ "errors"
+ "strconv"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/internal/sigv4auth"
+)
+
+const (
+ SigningRegion = "us-east-1"
+ timeExpiration = 15 * time.Minute
+)
+
+var requiredSignedHeaders = []string{"host"}
+
+type RootCredentials struct {
+ Access string
+ Secret string
+}
+
+func VerifyIAMAuth(root *RootCredentials) fiber.Handler {
+ return func(ctx fiber.Ctx) error {
+ authData, tdate, queryAuth, err := parseIAMAuth(ctx)
+ if err != nil {
+ return err
+ }
+
+ if authData.Access != root.Access {
+ return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
+ }
+
+ contentLength, err := parseContentLength(ctx.Get("Content-Length"))
+ if err != nil {
+ return err
+ }
+
+ payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw())
+ if queryAuth {
+ _, err = sigv4auth.CheckQuerySignature(ctx, authData, root.Secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
+ Service: sigv4auth.ServiceIAM,
+ RequiredSignedHeaders: requiredSignedHeaders,
+ })
+ } else {
+ _, err = sigv4auth.CheckSignature(ctx, authData, root.Secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
+ Service: sigv4auth.ServiceIAM,
+ RequiredSignedHeaders: requiredSignedHeaders,
+ })
+ }
+ if err != nil {
+ return mapIAMSigV4Error(err)
+ }
+
+ return nil
+ }
+}
+
+func parseIAMAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) {
+ if sigv4auth.IsQueryAuth(ctx) {
+ return parseIAMQueryAuth(ctx)
+ }
+ if sigv4auth.IsQueryAuthV2(ctx) {
+ return sigv4auth.AuthData{}, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrUnsupportedSignatureVersion)
+ }
+
+ return parseIAMHeaderAuth(ctx)
+}
+
+func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) {
+ authData := sigv4auth.AuthData{}
+
+ authorization := ctx.Get("Authorization")
+ if authorization == "" {
+ return authData, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)
+ }
+
+ date := ctx.Get("X-Amz-Date")
+ if date == "" {
+ date = ctx.Get("Date")
+ }
+ if date == "" {
+ return authData, time.Time{}, false, iamerr.IncompleteSignatureMissingDate(authorization)
+ }
+
+ tdate, err := time.Parse(sigv4auth.ISO8601Format, date)
+ if err != nil {
+ return authData, time.Time{}, false, iamerr.IncompleteSignatureInvalidXAmzDate(date)
+ }
+ if err := ValidateDateAt(tdate, time.Now().UTC()); err != nil {
+ return authData, time.Time{}, false, err
+ }
+
+ authData, err = sigv4auth.ParseAuthorization(authorization, sigv4auth.ServiceIAM)
+ if err != nil {
+ return authData, time.Time{}, false, mapIAMSigV4Error(err, authorization)
+ }
+
+ if authData.Region != SigningRegion {
+ return authData, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrInvalidRegion)
+ }
+ if date[:8] != authData.Date {
+ return authData, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate)
+ }
+
+ return authData, tdate, false, nil
+}
+
+func parseIAMQueryAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) {
+ if ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) {
+ return sigv4auth.AuthData{}, time.Time{}, true, mapIAMSigV4Error(&sigv4auth.QueryError{Kind: sigv4auth.ErrQuerySecurityToken})
+ }
+
+ authData, details, err := sigv4auth.ParseQueryAuthorization(ctx, sigv4auth.QueryAuthOptions{
+ Service: sigv4auth.ServiceIAM,
+ Region: SigningRegion,
+ })
+ if err != nil {
+ return authData, time.Time{}, true, mapIAMSigV4Error(err)
+ }
+ if err := ValidateDateAt(details.SigningTime, time.Now().UTC()); err != nil {
+ return authData, time.Time{}, true, err
+ }
+
+ return authData, details.SigningTime, true, nil
+}
+
+func parseContentLength(contentLengthStr string) (int64, error) {
+ if contentLengthStr == "" {
+ return 0, nil
+ }
+
+ contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
+ if err != nil {
+ return 0, iamerr.GetAPIError(iamerr.ErrInvalidContentLength)
+ }
+
+ return contentLength, nil
+}
+
+// ValidateDateAt checks that date is within the allowed window relative to now.
+// Exported so tests can exercise it directly.
+func ValidateDateAt(date, now time.Time) error {
+ if date.After(now.Add(timeExpiration)) {
+ return iamerr.SignatureDoesNotMatchNotYetCurrent(date, now, timeExpiration)
+ }
+ if date.Before(now.Add(-timeExpiration)) {
+ return iamerr.SignatureDoesNotMatchExpired(date, now, timeExpiration)
+ }
+ return nil
+}
+
+func mapIAMSigV4Error(err error, authorization ...string) error {
+ var queryErr *sigv4auth.QueryError
+ if errors.As(err, &queryErr) {
+ return mapIAMQueryError(queryErr)
+ }
+
+ var parseErr *sigv4auth.ParseError
+ if errors.As(err, &parseErr) {
+ authHeader := ""
+ if len(authorization) > 0 {
+ authHeader = authorization[0]
+ }
+ return mapIAMParseError(parseErr, authHeader)
+ }
+
+ var headersErr *sigv4auth.HeadersNotSignedError
+ if errors.As(err, &headersErr) {
+ if len(headersErr.Headers) == 1 && headersErr.Headers[0] == "host" {
+ return iamerr.GetAPIError(iamerr.ErrMissingHostSignedHeader)
+ }
+ return iamerr.IncompleteSignatureHeadersNotSigned(headersErr.Headers)
+ }
+
+ var sigErr *sigv4auth.SignatureMismatchError
+ if errors.As(err, &sigErr) {
+ return iamerr.GetAPIError(iamerr.ErrSignatureDoesNotMatch)
+ }
+
+ return err
+}
+
+func mapIAMQueryError(err *sigv4auth.QueryError) error {
+ switch err.Kind {
+ case sigv4auth.ErrQueryMissingRequiredParams:
+ switch err.Value {
+ case sigv4auth.QueryAlgorithm:
+ return iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)
+ case sigv4auth.QueryCredential, sigv4auth.QueryDate, sigv4auth.QuerySignedHeaders, sigv4auth.QuerySignature:
+ return iamerr.IncompleteSignatureMissingQueryParameter(err.Value)
+ default:
+ return iamerr.GetAPIError(iamerr.ErrIncompleteSignature)
+ }
+ case sigv4auth.ErrQueryUnsupportedAlgorithm, sigv4auth.ErrQueryUnsupportedECDSA:
+ return iamerr.GetAPIError(iamerr.ErrUnsupportedQueryAlgorithm)
+ case sigv4auth.ErrQueryInvalidDateFormat:
+ return iamerr.IncompleteSignatureInvalidXAmzDate(err.Value)
+ case sigv4auth.ErrQueryDateMismatch:
+ return iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate)
+ case sigv4auth.ErrQueryIncorrectRegion:
+ return iamerr.GetAPIError(iamerr.ErrInvalidRegion)
+ case sigv4auth.ErrQuerySecurityToken:
+ return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
+ default:
+ return iamerr.GetAPIError(iamerr.ErrIncompleteSignature)
+ }
+}
+
+func mapIAMParseError(err *sigv4auth.ParseError, authorization string) error {
+ if authorization == "" {
+ authorization = err.Input
+ }
+
+ switch err.Kind {
+ case sigv4auth.ErrInvalidAuthorizationHeader:
+ return iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)
+ case sigv4auth.ErrUnsupportedAuthorizationVersion:
+ return iamerr.GetAPIError(iamerr.ErrUnsupportedSignatureVersion)
+ case sigv4auth.ErrInvalidAuthorizationType:
+ return iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)
+ case sigv4auth.ErrMissingComponents:
+ return iamerr.GetAPIError(iamerr.ErrMissingAuthorizationComponents)
+ case sigv4auth.ErrMissingCredential:
+ return iamerr.IncompleteSignatureMissingAuthorizationComponent("Credential", authorization)
+ case sigv4auth.ErrMissingSignedHeaders:
+ return iamerr.IncompleteSignatureMissingAuthorizationComponent("SignedHeaders", authorization)
+ case sigv4auth.ErrMissingSignature:
+ return iamerr.IncompleteSignatureMissingAuthorizationComponent("Signature", authorization)
+ case sigv4auth.ErrMalformedComponent:
+ return iamerr.IncompleteSignatureMalformedComponent(err.Value)
+ case sigv4auth.ErrMalformedCredential:
+ return iamerr.IncompleteSignatureMalformedCredential(err.Input)
+ case sigv4auth.ErrIncorrectService:
+ return iamerr.GetAPIError(iamerr.ErrIncorrectService)
+ case sigv4auth.ErrIncorrectTerminal:
+ return iamerr.GetAPIError(iamerr.ErrInvalidTerminal)
+ case sigv4auth.ErrInvalidDateFormat:
+ return iamerr.GetAPIError(iamerr.ErrInvalidCredentialDate)
+ default:
+ return iamerr.GetAPIError(iamerr.ErrIncompleteSignature)
+ }
+}
diff --git a/iamapi/internal/iammiddleware/debug.go b/iamapi/internal/iammiddleware/debug.go
new file mode 100644
index 00000000..a7273d05
--- /dev/null
+++ b/iamapi/internal/iammiddleware/debug.go
@@ -0,0 +1,37 @@
+// 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 iammiddleware
+
+import (
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+// DebugLogger returns a middleware that logs full request and response details
+// when debug logging is enabled.
+func DebugLogger() fiber.Handler {
+ return func(ctx fiber.Ctx) error {
+ debuglogger.LogFiberRequestDetails(ctx)
+ err := ctx.Next()
+ debuglogger.LogFiberResponseDetails(ctx)
+ return err
+ }
+}
+
+// StackTraceHandler stores the panic value in the request context so that the
+// global error handler can distinguish panics from regular errors.
+func StackTraceHandler(ctx fiber.Ctx, e any) {
+ httpctx.ContextKeyStack.Set(ctx, e)
+}
diff --git a/iamapi/internal/iammiddleware/errors.go b/iamapi/internal/iammiddleware/errors.go
new file mode 100644
index 00000000..fb8df834
--- /dev/null
+++ b/iamapi/internal/iammiddleware/errors.go
@@ -0,0 +1,44 @@
+// 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 iammiddleware
+
+import (
+ "errors"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+// GlobalErrorHandler is the fiber error handler for the IAM API server. It
+// translates APIError values into XML responses and logs unexpected errors.
+func GlobalErrorHandler(ctx fiber.Ctx, er error) error {
+ requestID := EnsureRequestID(ctx)
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+
+ var apiErr iamerr.APIError
+ if errors.As(er, &apiErr) {
+ return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID))
+ }
+
+ if httpctx.ContextKeyStack.IsSet(ctx) {
+ debuglogger.Panic(er)
+ } else {
+ debuglogger.InternalError(er)
+ }
+
+ err := iamerr.GetAPIError(iamerr.ErrInternalFailure)
+ return ctx.Status(err.StatusCode()).Send(err.XMLBody(requestID))
+}
diff --git a/iamapi/internal/iammiddleware/ratelimiter.go b/iamapi/internal/iammiddleware/ratelimiter.go
new file mode 100644
index 00000000..8b2dd0f2
--- /dev/null
+++ b/iamapi/internal/iammiddleware/ratelimiter.go
@@ -0,0 +1,38 @@
+// 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 iammiddleware
+
+import (
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "golang.org/x/sync/semaphore"
+)
+
+// RateLimiter returns a middleware that limits concurrent in-flight requests to
+// limit. Excess requests receive a Throttling error response immediately.
+func RateLimiter(limit int) fiber.Handler {
+ sem := semaphore.NewWeighted(int64(limit))
+
+ return func(ctx fiber.Ctx) error {
+ requestID := EnsureRequestID(ctx)
+
+ if !sem.TryAcquire(1) {
+ err := iamerr.GetAPIError(iamerr.ErrThrottling)
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+ return ctx.Status(err.StatusCode()).Send(err.XMLBody(requestID))
+ }
+ defer sem.Release(1)
+ return ctx.Next()
+ }
+}
diff --git a/iamapi/internal/iammiddleware/requestid.go b/iamapi/internal/iammiddleware/requestid.go
new file mode 100644
index 00000000..4ca7934c
--- /dev/null
+++ b/iamapi/internal/iammiddleware/requestid.go
@@ -0,0 +1,45 @@
+// 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 iammiddleware
+
+import (
+ "github.com/gofiber/fiber/v3"
+ "github.com/google/uuid"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+const HeaderAmznRequestID = "x-amzn-RequestId"
+
+// RequestIDs is a middleware that ensures every request has a request ID set
+// and returned in the response header.
+func RequestIDs() fiber.Handler {
+ return func(ctx fiber.Ctx) error {
+ EnsureRequestID(ctx)
+ return ctx.Next()
+ }
+}
+
+// EnsureRequestID returns the existing request ID from the context, or
+// generates and stores a new one if none exists. It always sets the
+// x-amzn-RequestId response header.
+func EnsureRequestID(ctx fiber.Ctx) string {
+ requestID, _ := httpctx.ContextKeyRequestID.Get(ctx).(string)
+ if requestID == "" {
+ requestID = uuid.NewString()
+ httpctx.ContextKeyRequestID.Set(ctx, requestID)
+ }
+
+ ctx.Response().Header.Set(HeaderAmznRequestID, requestID)
+ return requestID
+}
diff --git a/iamapi/internal/iamutil/request.go b/iamapi/internal/iamutil/request.go
new file mode 100644
index 00000000..d03d70c4
--- /dev/null
+++ b/iamapi/internal/iamutil/request.go
@@ -0,0 +1,40 @@
+// 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 iamutil
+
+import (
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+// MatchQueryOrFormArgs matches AWS Query-style requests that contain all
+// provided parameters in either the URL query string or a form body.
+func MatchQueryOrFormArgs(args ...string) fiber.Handler {
+ return func(ctx fiber.Ctx) error {
+ if httpctx.ContextKeySkip.IsSet(ctx) {
+ return ctx.Next()
+ }
+
+ queryArgs := ctx.Request().URI().QueryArgs()
+ formArgs := ctx.Request().PostArgs()
+ for _, arg := range args {
+ if !queryArgs.Has(arg) && !formArgs.Has(arg) {
+ httpctx.ContextKeySkip.Set(ctx, true)
+ break
+ }
+ }
+
+ return ctx.Next()
+ }
+}
diff --git a/iamapi/internal/iamutil/request_test.go b/iamapi/internal/iamutil/request_test.go
new file mode 100644
index 00000000..cce8c688
--- /dev/null
+++ b/iamapi/internal/iamutil/request_test.go
@@ -0,0 +1,74 @@
+// 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 iamutil
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+func TestMatchQueryOrFormArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ target string
+ body string
+ contentType string
+ want string
+ }{
+ {name: "query", method: http.MethodGet, target: "/any?Action=ListUsers", want: "matched"},
+ {name: "empty query value is present", method: http.MethodGet, target: "/any?Action=", want: "matched"},
+ {name: "form", method: http.MethodPost, target: "/any", body: "Action=ListUsers", contentType: fiber.MIMEApplicationForm, want: "matched"},
+ {name: "missing", method: http.MethodGet, target: "/any", want: "fallback"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ app := fiber.New()
+ app.Add([]string{http.MethodGet, http.MethodPost}, "/*",
+ MatchQueryOrFormArgs("Action"),
+ func(ctx fiber.Ctx) error {
+ if httpctx.ContextKeySkip.IsSet(ctx) {
+ httpctx.ContextKeySkip.Delete(ctx)
+ return ctx.Next()
+ }
+ return ctx.SendString("matched")
+ },
+ )
+ app.All("*", func(ctx fiber.Ctx) error { return ctx.SendString("fallback") })
+
+ req := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body))
+ if tt.contentType != "" {
+ req.Header.Set("Content-Type", tt.contentType)
+ }
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if string(body) != tt.want {
+ t.Fatalf("body = %q, want %q", string(body), tt.want)
+ }
+ })
+ }
+}
diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go
new file mode 100644
index 00000000..1922aa85
--- /dev/null
+++ b/iamapi/internal/iamutil/user.go
@@ -0,0 +1,213 @@
+// 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 iamutil
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "regexp"
+ "strings"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/types"
+)
+
+const (
+ DefaultAccountID = "000000000000"
+ DefaultUserPath = "/"
+ DefaultMaxItems = 100
+ MaxListItems = 1000
+ MaxUserNameLen = 64
+ MaxUserLookupLen = 128
+ MaxPathLen = 512
+ userIDPrefix = "AIDA"
+ userIDRandomLen = 17
+ userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+ maxTagKeyLen = 128
+ maxTagValLen = 256
+)
+
+var (
+ userNamePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`)
+ tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`)
+ tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`)
+)
+
+// RequestParam looks up key first in URL query args, then in the POST body.
+func RequestParam(ctx fiber.Ctx, key string) (string, bool) {
+ queryArgs := ctx.Request().URI().QueryArgs()
+ if queryArgs.Has(key) {
+ return string(queryArgs.Peek(key)), true
+ }
+
+ postArgs := ctx.Request().PostArgs()
+ if postArgs.Has(key) {
+ return string(postArgs.Peek(key)), true
+ }
+
+ return "", false
+}
+
+// ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list.
+func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
+ var tags []types.Tag
+ seen := map[string]struct{}{}
+
+ for i := 1; ; i++ {
+ keyName := fmt.Sprintf("Tags.member.%d.Key", i)
+ valueName := fmt.Sprintf("Tags.member.%d.Value", i)
+
+ key, hasKey := RequestParam(ctx, keyName)
+ value, hasValue := RequestParam(ctx, valueName)
+ if !hasKey && !hasValue {
+ break
+ }
+ if len(tags) >= 50 {
+ debuglogger.Logf("IAM user tag count exceeds maximum: max=%d", 50)
+ return nil, iamerr.GetAPIError(iamerr.ErrTooManyTags)
+ }
+ if !hasKey {
+ debuglogger.Logf("missing required IAM tag parameter: %s", keyName)
+ return nil, iamerr.MissingParameter(keyName)
+ }
+ if !hasValue {
+ debuglogger.Logf("missing required IAM tag parameter: %s", valueName)
+ return nil, iamerr.MissingParameter(valueName)
+ }
+ if err := validateTag(i, key, value); err != nil {
+ return nil, err
+ }
+
+ normalizedKey := strings.ToLower(key)
+ if _, ok := seen[normalizedKey]; ok {
+ debuglogger.Logf("duplicate IAM tag key: %q", key)
+ return nil, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)
+ }
+ seen[normalizedKey] = struct{}{}
+
+ tags = append(tags, types.Tag{Key: key, Value: value})
+ }
+
+ return tags, nil
+}
+
+// ValidateUserName checks that userName is non-empty, matches the allowed character set, and fits within maxLength.
+func ValidateUserName(field, userName string, maxLength int) error {
+ if len(userName) > maxLength {
+ debuglogger.Logf("IAM user name exceeds maximum length: field=%s length=%d max=%d", field, len(userName), maxLength)
+ return iamerr.UserNameTooLong(field, maxLength)
+ }
+ if userName == "" || !userNamePattern.MatchString(userName) {
+ debuglogger.Logf("invalid IAM user name: field=%s value=%q", field, userName)
+ return iamerr.InvalidUserName(field)
+ }
+
+ return nil
+}
+
+// ValidatePath checks that path is a valid IAM path (must start and end with '/') within MaxPathLen.
+func ValidatePath(field, path string) error {
+ if len(path) > MaxPathLen {
+ debuglogger.Logf("IAM path exceeds maximum length: field=%s length=%d max=%d", field, len(path), MaxPathLen)
+ return iamerr.PathTooLong(field, MaxPathLen)
+ }
+ if !isValidIAMPath(path) {
+ debuglogger.Logf("invalid IAM path: field=%s value=%q", field, path)
+ return iamerr.InvalidPath(field)
+ }
+
+ return nil
+}
+
+// ValidatePathPrefix checks that pathPrefix is a non-empty printable ASCII string starting with '/'.
+func ValidatePathPrefix(pathPrefix string) error {
+ if pathPrefix == "" || len(pathPrefix) > MaxPathLen || pathPrefix[0] != '/' || !isPrintableASCII(pathPrefix[1:]) {
+ debuglogger.Logf("invalid IAM path prefix: %q", pathPrefix)
+ return iamerr.GetAPIError(iamerr.ErrInvalidPathPrefix)
+ }
+
+ return nil
+}
+
+// BuildUserArn constructs the ARN for an IAM user.
+func BuildUserArn(accountID, path, userName string) string {
+ return fmt.Sprintf("arn:aws:iam::%s:user%s%s", accountID, path, userName)
+}
+
+// GenerateUserID returns a new cryptographically random IAM user ID in the AIDA… format.
+func GenerateUserID() (string, error) {
+ var b strings.Builder
+ b.Grow(len(userIDPrefix) + userIDRandomLen)
+ b.WriteString(userIDPrefix)
+
+ max := big.NewInt(int64(len(userIDAlphabet)))
+ for range userIDRandomLen {
+ n, err := rand.Int(rand.Reader, max)
+ if err != nil {
+ debuglogger.Logf("failed to generate IAM user ID: %v", err)
+ return "", err
+ }
+ b.WriteByte(userIDAlphabet[n.Int64()])
+ }
+
+ return b.String(), nil
+}
+
+func validateTag(index int, key, value string) error {
+ if len(key) > maxTagKeyLen {
+ debuglogger.Logf("IAM tag key exceeds maximum length: index=%d length=%d max=%d", index, len(key), maxTagKeyLen)
+ return iamerr.TagKeyTooLong(index)
+ }
+ if key == "" || !tagKeyPattern.MatchString(key) {
+ debuglogger.Logf("invalid IAM tag key: index=%d value=%q", index, key)
+ return iamerr.InvalidTagKey(index)
+ }
+ if len(value) > maxTagValLen {
+ debuglogger.Logf("IAM tag value exceeds maximum length: index=%d length=%d max=%d", index, len(value), maxTagValLen)
+ return iamerr.TagValueTooLong(index)
+ }
+ if !tagValPattern.MatchString(value) {
+ debuglogger.Logf("invalid IAM tag value: index=%d value=%q", index, value)
+ return iamerr.InvalidTagValue(index)
+ }
+
+ return nil
+}
+
+func isValidIAMPath(path string) bool {
+ if path == "" || len(path) > MaxPathLen {
+ return false
+ }
+ if path == "/" {
+ return true
+ }
+ if path[0] != '/' || path[len(path)-1] != '/' {
+ return false
+ }
+
+ return isPrintableASCII(path[1 : len(path)-1])
+}
+
+func isPrintableASCII(value string) bool {
+ for i := 0; i < len(value); i++ {
+ if value[i] < 0x21 || value[i] > 0x7e {
+ return false
+ }
+ }
+ return true
+}
diff --git a/iamapi/response.go b/iamapi/response.go
new file mode 100644
index 00000000..ee799e09
--- /dev/null
+++ b/iamapi/response.go
@@ -0,0 +1,136 @@
+// 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 iamapi
+
+import (
+ "encoding/xml"
+ "net/http"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+ "github.com/versity/versitygw/iamapi/types"
+ "github.com/versity/versitygw/internal/httpctx"
+)
+
+var xmlhdr = []byte(xml.Header)
+
+const (
+ // HeaderAmznRequestID is the response header that carries the request ID.
+ // Re-exported from iammiddleware so callers only need to import iamapi.
+ HeaderAmznRequestID = iammiddleware.HeaderAmznRequestID
+ maxXMLBodyLen = 4 * 1024 * 1024
+)
+
+type Response struct {
+ Data types.ActionResponse
+ Headers map[string]*string
+ Status int
+}
+
+type ActionHandler func(ctx fiber.Ctx) (*Response, error)
+
+func ProcessHandlers(controller ActionHandler, handlers ...fiber.Handler) fiber.Handler {
+ return func(ctx fiber.Ctx) error {
+ if httpctx.ContextKeySkip.IsSet(ctx) {
+ httpctx.ContextKeySkip.Delete(ctx)
+ return ctx.Next()
+ }
+
+ for _, handler := range handlers {
+ if err := handler(ctx); err != nil {
+ return ProcessController(ctx, func(ctx fiber.Ctx) (*Response, error) {
+ return &Response{}, err
+ })
+ }
+ }
+
+ return ProcessController(ctx, controller)
+ }
+}
+
+func ProcessController(ctx fiber.Ctx, controller ActionHandler) error {
+ response, err := controller(ctx)
+ if response == nil {
+ response = &Response{}
+ }
+
+ SetResponseHeaders(ctx, response.Headers)
+ requestID := iammiddleware.EnsureRequestID(ctx)
+
+ if err != nil {
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+
+ if apiErr, ok := err.(iamerr.APIError); ok {
+ return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID))
+ }
+
+ debuglogger.InternalError(err)
+ internalErr := iamerr.GetAPIError(iamerr.ErrInternalFailure)
+ return ctx.Status(internalErr.StatusCode()).Send(internalErr.XMLBody(requestID))
+ }
+
+ status := response.Status
+ if status == 0 {
+ status = http.StatusOK
+ }
+
+ if response.Data == nil {
+ ctx.Status(status)
+ return nil
+ }
+
+ response.Data.SetRequestID(requestID)
+
+ responseBytes, err := xml.Marshal(response.Data)
+ if err != nil {
+ debuglogger.InternalError(err)
+ internalErr := iamerr.GetAPIError(iamerr.ErrInternalFailure)
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+ return ctx.Status(internalErr.StatusCode()).Send(internalErr.XMLBody(requestID))
+ }
+
+ msglen := len(xmlhdr) + len(responseBytes)
+ if msglen > maxXMLBodyLen {
+ debuglogger.Logf("XML encoded body len %v exceeds max len %v", msglen, maxXMLBodyLen)
+ internalErr := iamerr.GetAPIError(iamerr.ErrInternalFailure)
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+ return ctx.Status(internalErr.StatusCode()).Send(internalErr.XMLBody(requestID))
+ }
+
+ res := make([]byte, 0, msglen)
+ res = append(res, xmlhdr...)
+ res = append(res, responseBytes...)
+
+ ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
+ ctx.Response().Header.SetContentLength(msglen)
+
+ return ctx.Status(status).Send(res)
+}
+
+func SetResponseHeaders(ctx fiber.Ctx, headers map[string]*string) {
+ if headers == nil {
+ return
+ }
+
+ ctx.Response().Header.DisableNormalizing()
+ for key, val := range headers {
+ if val == nil || *val == "" {
+ continue
+ }
+ ctx.Response().Header.Add(key, *val)
+ }
+}
diff --git a/iamapi/router.go b/iamapi/router.go
new file mode 100644
index 00000000..823cba05
--- /dev/null
+++ b/iamapi/router.go
@@ -0,0 +1,91 @@
+// 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 iamapi
+
+import (
+ "net/http"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+ "github.com/versity/versitygw/iamapi/internal/iamutil"
+ "github.com/versity/versitygw/iamapi/storage"
+)
+
+const (
+ iamAPIVersion = "2010-05-08"
+ noVersionSpecified = "NO_VERSION_SPECIFIED"
+ productURL = "https://www.versity.com/products/versitygw/"
+)
+
+var unknownOperationBody = []byte("\n")
+
+type IAMApiRouter struct {
+ app *fiber.App
+ store storage.Storer
+ Ctrl IAMApiController
+ actions map[string]ActionHandler
+ rootCreds *RootCredentials
+}
+
+func (r *IAMApiRouter) Init() {
+ ctrl := NewController(r.store)
+ r.Ctrl = ctrl
+
+ r.actions = map[string]ActionHandler{
+ "CreateUser": ctrl.CreateUser,
+ "DeleteUser": ctrl.DeleteUser,
+ "GetUser": ctrl.GetUser,
+ "ListUsers": ctrl.ListUsers,
+ "UpdateUser": ctrl.UpdateUser,
+ }
+
+ actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds))
+ r.app.Get("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute)
+ r.app.Post("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute)
+
+ r.app.All("/", r.redirectRoot)
+ r.app.All("*", r.unknownOperation)
+}
+
+func (r *IAMApiRouter) routeAction(ctx fiber.Ctx) (*Response, error) {
+ action, _ := iamutil.RequestParam(ctx, "Action")
+ version, versionSpecified := iamutil.RequestParam(ctx, "Version")
+ if !versionSpecified {
+ version = noVersionSpecified
+ }
+ if version != iamAPIVersion {
+ return &Response{}, iamerr.InvalidAction(action, version)
+ }
+
+ handler, ok := r.actions[action]
+ if !ok {
+ return &Response{}, iamerr.InvalidAction(action, version)
+ }
+
+ return handler(ctx)
+}
+
+func (r *IAMApiRouter) redirectRoot(ctx fiber.Ctx) error {
+ iammiddleware.EnsureRequestID(ctx)
+ ctx.Set(fiber.HeaderLocation, productURL)
+ ctx.Status(http.StatusFound)
+ return nil
+}
+
+func (r *IAMApiRouter) unknownOperation(ctx fiber.Ctx) error {
+ iammiddleware.EnsureRequestID(ctx)
+ return ctx.Status(http.StatusNotFound).Send(unknownOperationBody)
+}
diff --git a/iamapi/router_test.go b/iamapi/router_test.go
new file mode 100644
index 00000000..2c2473ca
--- /dev/null
+++ b/iamapi/router_test.go
@@ -0,0 +1,206 @@
+// 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 iamapi
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+)
+
+func TestIAMApiRouter_InitRegistersGetAndPostActionRoutesForAnyPath(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{app: app}
+ router.Init()
+
+ methodCounts := map[string]int{}
+ for _, routes := range app.Stack() {
+ for _, route := range routes {
+ if route.Path != "/*" {
+ continue
+ }
+ methodCounts[route.Method]++
+ }
+ }
+
+ // GET and POST each have an action route plus the all-method fallback;
+ // other methods have only the fallback.
+ if methodCounts[http.MethodGet] != 2 || methodCounts[http.MethodPost] != 2 || methodCounts[http.MethodPut] != 1 {
+ t.Fatalf("wildcard route method counts = %v", methodCounts)
+ }
+}
+
+func TestIAMApiRouter_RouteActionDetectsQueryAction(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{
+ actions: map[string]ActionHandler{
+ "GetUser": func(ctx fiber.Ctx) (*Response, error) {
+ return &Response{Status: http.StatusAccepted}, nil
+ },
+ },
+ }
+ app.Get("/", ProcessHandlers(router.routeAction))
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/?Action=GetUser&Version="+iamAPIVersion, nil))
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ if resp.StatusCode != http.StatusAccepted {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted)
+ }
+}
+
+func TestIAMApiRouter_RouteActionDetectsFormAction(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{
+ actions: map[string]ActionHandler{
+ "CreateUser": func(ctx fiber.Ctx) (*Response, error) {
+ return &Response{Status: http.StatusCreated}, nil
+ },
+ },
+ }
+ app.Post("/", ProcessHandlers(router.routeAction))
+
+ req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("Action=CreateUser&Version="+iamAPIVersion))
+ req.Header.Set("Content-Type", fiber.MIMEApplicationForm)
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusCreated)
+ }
+}
+
+func TestIAMApiRouter_RouteActionValidatesVersionBeforeAction(t *testing.T) {
+ tests := []struct {
+ name string
+ target string
+ message string
+ }{
+ {
+ name: "missing version",
+ target: "/?Action=ListUsers",
+ message: "Could not find operation ListUsers for version NO_VERSION_SPECIFIED",
+ },
+ {
+ name: "invalid version",
+ target: "/?Action=ListUsers&Version=this-is-custom-invalid-version",
+ message: "Could not find operation ListUsers for version this-is-custom-invalid-version",
+ },
+ {
+ name: "unknown action",
+ target: "/?Action=ListUserssssss&Version=" + iamAPIVersion,
+ message: "Could not find operation ListUserssssss for version 2010-05-08",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{actions: map[string]ActionHandler{}}
+ app.Get("/", ProcessHandlers(router.routeAction))
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, tt.target, nil))
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ requireIAMError(t, resp, http.StatusBadRequest, "Sender", "InvalidAction", tt.message)
+ })
+ }
+}
+
+func TestIAMApiRouter_ActionRoutesMatchAnyPath(t *testing.T) {
+ app := fiber.New(fiber.Config{ErrorHandler: iammiddleware.GlobalErrorHandler})
+ router := &IAMApiRouter{app: app, rootCreds: &testRoot}
+ router.Init()
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/any/nested/path?Action=ListUsers&Version="+iamAPIVersion, nil))
+ if err != nil {
+ t.Fatalf("GET app.Test: %v", err)
+ }
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "MissingAuthenticationToken", "Request is missing Authentication Token")
+
+ req := httptest.NewRequest(http.MethodPost, "/another/path", bytes.NewBufferString("Action=ListUsers&Version="+iamAPIVersion))
+ req.Header.Set("Content-Type", fiber.MIMEApplicationForm)
+ resp, err = app.Test(req)
+ if err != nil {
+ t.Fatalf("POST app.Test: %v", err)
+ }
+ requireIAMError(t, resp, http.StatusForbidden, "Sender", "MissingAuthenticationToken", "Request is missing Authentication Token")
+}
+
+func TestIAMApiRouter_RootWithoutActionRedirects(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{app: app}
+ router.Init()
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil))
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
+ }
+ if got := resp.Header.Get("Location"); got != productURL {
+ t.Fatalf("Location = %q, want %q", got, productURL)
+ }
+ if got := resp.Header.Get(HeaderAmznRequestID); got == "" {
+ t.Fatal("missing x-amzn-RequestId")
+ }
+ if got := resp.Header.Get("Content-Length"); got != "0" {
+ t.Fatalf("Content-Length = %q, want 0", got)
+ }
+ if len(body) != 0 {
+ t.Fatalf("body = %q, want empty", string(body))
+ }
+}
+
+func TestIAMApiRouter_UnmatchedRouteReturnsUnknownOperation(t *testing.T) {
+ app := fiber.New()
+ router := &IAMApiRouter{app: app}
+ router.Init()
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/not-an-action-route", nil))
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
+ }
+ if string(body) != string(unknownOperationBody) {
+ t.Fatalf("body = %q, want %q", string(body), string(unknownOperationBody))
+ }
+ if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(unknownOperationBody)) {
+ t.Fatalf("Content-Length = %q, want %d", got, len(unknownOperationBody))
+ }
+ if got := resp.Header.Get(HeaderAmznRequestID); got == "" {
+ t.Fatal("missing x-amzn-RequestId")
+ }
+}
diff --git a/iamapi/server.go b/iamapi/server.go
new file mode 100644
index 00000000..c5ccbe47
--- /dev/null
+++ b/iamapi/server.go
@@ -0,0 +1,207 @@
+// 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 iamapi
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/gofiber/fiber/v3/middleware/logger"
+ "github.com/gofiber/fiber/v3/middleware/recover"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/iamapi/internal/iammiddleware"
+ "github.com/versity/versitygw/iamapi/storage"
+ "github.com/versity/versitygw/internal/netutil"
+)
+
+const (
+ shutDownDuration = time.Second * 10
+ requestHeaderMaxSize = 8 * 1024
+)
+
+// RootCredentials re-exports the type from iammiddleware so callers only need
+// to import iamapi.
+type RootCredentials = iammiddleware.RootCredentials
+
+type CertStorage = netutil.CertStorage
+
+func NewCertStorage() *CertStorage {
+ return netutil.NewCertStorage()
+}
+
+type IAMApiServer struct {
+ Router *IAMApiRouter
+ app *fiber.App
+ store storage.Storer
+ rootCreds *RootCredentials
+ CertStorage *CertStorage
+ quiet bool
+ keepAlive bool
+ health string
+ maxConnections int
+ maxRequests int
+ socketPerm os.FileMode
+ onListen func()
+}
+
+func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) {
+ if store == nil {
+ return nil, fmt.Errorf("iamapi: storer is required")
+ }
+
+ server := &IAMApiServer{
+ store: store,
+ Router: &IAMApiRouter{
+ store: store,
+ },
+ }
+
+ for _, opt := range opts {
+ opt(server)
+ }
+
+ app := fiber.New(fiber.Config{
+ AppName: "versitygw-iam",
+ ServerHeader: "VERSITYGW",
+ DisableKeepalive: !server.keepAlive,
+ ErrorHandler: iammiddleware.GlobalErrorHandler,
+ Concurrency: server.maxConnections,
+ ReadBufferSize: requestHeaderMaxSize,
+ StreamRequestBody: false,
+ })
+
+ server.app = app
+ server.Router.app = app
+ server.Router.rootCreds = server.rootCreds
+
+ app.Use("*", recover.New(recover.Config{
+ EnableStackTrace: true,
+ StackTraceHandler: iammiddleware.StackTraceHandler,
+ }))
+
+ if !server.quiet {
+ app.Use("*", logger.New(logger.Config{
+ Format: "${time} | vgw-iam | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
+ }))
+ }
+
+ app.Use("*", iammiddleware.RequestIDs())
+
+ if server.health != "" {
+ app.Get(server.health, func(ctx fiber.Ctx) error {
+ return ctx.SendStatus(http.StatusOK)
+ })
+ }
+
+ if server.maxRequests > 0 {
+ app.Use("*", iammiddleware.RateLimiter(server.maxRequests))
+ }
+
+ if debuglogger.IsDebugEnabled() {
+ app.Use("*", iammiddleware.DebugLogger())
+ }
+
+ server.Router.Init()
+
+ return server, nil
+}
+
+type Option func(*IAMApiServer)
+
+func WithTLS(cs *CertStorage) Option {
+ return func(s *IAMApiServer) { s.CertStorage = cs }
+}
+
+func WithQuiet() Option {
+ return func(s *IAMApiServer) { s.quiet = true }
+}
+
+func WithHealth(health string) Option {
+ return func(s *IAMApiServer) { s.health = health }
+}
+
+func WithKeepAlive() Option {
+ return func(s *IAMApiServer) { s.keepAlive = true }
+}
+
+func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
+ return func(s *IAMApiServer) {
+ s.maxConnections = maxConnections
+ s.maxRequests = maxRequests
+ }
+}
+
+func WithSocketPerm(perm os.FileMode) Option {
+ return func(s *IAMApiServer) { s.socketPerm = perm }
+}
+
+func WithOnListen(fn func()) Option {
+ return func(s *IAMApiServer) { s.onListen = fn }
+}
+
+func WithRootUserCreds(root RootCredentials) Option {
+ return func(s *IAMApiServer) {
+ s.rootCreds = &root
+ }
+}
+
+func (s *IAMApiServer) ServeMultiPort(ports []string) error {
+ if len(ports) == 0 {
+ return fmt.Errorf("no ports specified")
+ }
+
+ var listeners []net.Listener
+ for _, portSpec := range ports {
+ var ln net.Listener
+ var err error
+
+ if s.CertStorage != nil {
+ ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, s.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: s.socketPerm})
+ } else {
+ ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm})
+ }
+ if err != nil {
+ return fmt.Errorf("failed to bind iam listener %s: %w", portSpec, err)
+ }
+
+ listeners = append(listeners, ln)
+ }
+
+ if len(listeners) == 0 {
+ return fmt.Errorf("failed to create any iam listeners")
+ }
+
+ finalListener := netutil.NewMultiListener(listeners...)
+
+ if s.onListen != nil {
+ fn := s.onListen
+ s.app.Hooks().OnListen(func(fiber.ListenData) error {
+ fn()
+ return nil
+ })
+ }
+
+ return s.app.Listener(finalListener, fiber.ListenConfig{
+ DisableStartupMessage: true,
+ })
+}
+
+func (s *IAMApiServer) Shutdown() error {
+ return s.app.ShutdownWithTimeout(shutDownDuration)
+}
diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go
new file mode 100644
index 00000000..be4e0f09
--- /dev/null
+++ b/iamapi/storage/internal.go
@@ -0,0 +1,233 @@
+// 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 storage
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/types"
+ "github.com/versity/versitygw/internal/iamstore"
+)
+
+const (
+ iamFile = "iam.json"
+ iamBackupFile = "iam.json.backup"
+)
+
+type InternalStore struct {
+ sync.RWMutex
+ engine *iamstore.Engine[iamConfig]
+}
+
+var _ Storer = (*InternalStore)(nil)
+
+func NewInternal(dir string) (Storer, error) {
+ engine, err := iamstore.New(dir, iamFile, iamBackupFile, defaultIAMConfig(), normalizeIAMConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ return &InternalStore{engine: engine}, nil
+}
+
+type iamConfig struct {
+ Users map[string]types.User `json:"users"`
+}
+
+func defaultIAMConfig() iamConfig {
+ return iamConfig{Users: map[string]types.User{}}
+}
+
+func normalizeIAMConfig(conf *iamConfig) {
+ if conf.Users == nil {
+ conf.Users = make(map[string]types.User)
+ }
+}
+
+func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) {
+ s.Lock()
+ defer s.Unlock()
+
+ if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := s.engine.ParseIAM(data)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, ok := conf.Users[user.UserName]; ok {
+ return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
+ }
+ for _, existing := range conf.Users {
+ if existing.UserID == user.UserID {
+ return nil, ErrUserIDAlreadyExists
+ }
+ }
+
+ conf.Users[user.UserName] = user
+ return json.Marshal(conf)
+ }); err != nil {
+ return nil, unwrapAPIError(err)
+ }
+
+ return cloneUser(user), nil
+}
+
+func (s *InternalStore) DeleteUser(_ context.Context, username string) error {
+ s.Lock()
+ defer s.Unlock()
+
+ err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := s.engine.ParseIAM(data)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, ok := conf.Users[username]; !ok {
+ return nil, iamerr.NoSuchEntityUser(username)
+ }
+
+ delete(conf.Users, username)
+ return json.Marshal(conf)
+ })
+ return unwrapAPIError(err)
+}
+
+func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User, error) {
+ s.RLock()
+ defer s.RUnlock()
+
+ conf, err := s.engine.GetIAM()
+ if err != nil {
+ return nil, err
+ }
+
+ user, ok := conf.Users[username]
+ if !ok {
+ return nil, iamerr.NoSuchEntityUser(username)
+ }
+
+ return cloneUser(user), nil
+}
+
+func (s *InternalStore) ListUsers(_ context.Context, input ListUsersInput) (*ListUsersOutput, error) {
+ s.RLock()
+ defer s.RUnlock()
+
+ conf, err := s.engine.GetIAM()
+ if err != nil {
+ return nil, err
+ }
+
+ users := make([]types.User, 0, len(conf.Users))
+ for _, user := range conf.Users {
+ if input.PathPrefix != "" && !strings.HasPrefix(user.Path, input.PathPrefix) {
+ continue
+ }
+ users = append(users, user)
+ }
+ sort.Slice(users, func(i, j int) bool {
+ return users[i].UserName < users[j].UserName
+ })
+
+ start := 0
+ if input.Marker != "" {
+ start = len(users)
+ for i, user := range users {
+ if user.UserName == input.Marker {
+ start = i + 1
+ break
+ }
+ }
+ }
+ users = users[start:]
+
+ limit := len(users)
+ if input.MaxItems > 0 && int(input.MaxItems) < limit {
+ limit = int(input.MaxItems)
+ }
+
+ out := &ListUsersOutput{
+ Users: make([]types.User, limit),
+ }
+ copy(out.Users, users[:limit])
+ if limit < len(users) {
+ out.IsTruncated = true
+ out.Marker = out.Users[limit-1].UserName
+ }
+
+ return out, nil
+}
+
+func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*types.User, error) {
+ s.Lock()
+ defer s.Unlock()
+
+ var updated types.User
+ if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := s.engine.ParseIAM(data)
+ if err != nil {
+ return nil, err
+ }
+
+ user, ok := conf.Users[input.UserName]
+ if !ok {
+ return nil, iamerr.NoSuchEntityUser(input.UserName)
+ }
+
+ finalName := user.UserName
+ if input.NewUserName != "" {
+ finalName = input.NewUserName
+ }
+ if finalName != input.UserName {
+ if _, ok := conf.Users[finalName]; ok {
+ return nil, iamerr.EntityAlreadyExistsUser(finalName)
+ }
+ }
+
+ if input.NewPath != "" {
+ user.Path = input.NewPath
+ }
+ if input.NewUserName != "" {
+ user.UserName = input.NewUserName
+ }
+ if input.NewArn != "" {
+ user.Arn = input.NewArn
+ }
+
+ if user.UserName != input.UserName {
+ delete(conf.Users, input.UserName)
+ }
+ conf.Users[user.UserName] = user
+ updated = user
+
+ return json.Marshal(conf)
+ }); err != nil {
+ return nil, unwrapAPIError(err)
+ }
+
+ return cloneUser(updated), nil
+}
+
+func cloneUser(user types.User) *types.User {
+ cloned := user
+ cloned.Tags = slices.Clone(user.Tags)
+ return &cloned
+}
diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go
new file mode 100644
index 00000000..c2544815
--- /dev/null
+++ b/iamapi/storage/storer.go
@@ -0,0 +1,109 @@
+// 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 storage
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/types"
+)
+
+var (
+ ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
+)
+
+type ListUsersInput struct {
+ PathPrefix string
+ Marker string
+ MaxItems int32
+}
+
+type ListUsersOutput struct {
+ Users []types.User
+ IsTruncated bool
+ Marker string
+}
+
+type UpdateUserInput struct {
+ UserName string
+ NewPath string
+ NewUserName string
+ NewArn string
+}
+
+// Storer is the IAM API storage backend contract.
+type Storer interface {
+ CreateUser(ctx context.Context, user types.User) (*types.User, error)
+ DeleteUser(ctx context.Context, username string) error
+ GetUser(ctx context.Context, username string) (*types.User, error)
+ ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error)
+ UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error)
+}
+
+func unwrapAPIError(err error) error {
+ var apiErr iamerr.APIError
+ if errors.As(err, &apiErr) {
+ return apiErr
+ }
+
+ return err
+}
+
+type Config struct {
+ Dir string
+ Vault VaultConfig
+}
+
+func New(cfg Config) (Storer, error) {
+ dir := strings.TrimSpace(cfg.Dir)
+ vaultEndpoint := strings.TrimSpace(cfg.Vault.EndpointURL)
+
+ selected := make([]string, 0, 2)
+ if dir != "" {
+ selected = append(selected, "dir")
+ }
+ if vaultEndpoint != "" {
+ selected = append(selected, "vault")
+ }
+
+ switch len(selected) {
+ case 0:
+ return nil, fmt.Errorf("no IAM storer config specified")
+ case 1:
+ default:
+ return nil, fmt.Errorf("multiple IAM storer configs specified: %s", strings.Join(selected, ", "))
+ }
+
+ switch {
+ case dir != "":
+ store, err := NewInternal(dir)
+ if err != nil {
+ return nil, fmt.Errorf("init internal IAM storer: %w", err)
+ }
+ return store, nil
+ case vaultEndpoint != "":
+ store, err := NewVault(cfg.Vault)
+ if err != nil {
+ return nil, fmt.Errorf("init vault IAM storer: %w", err)
+ }
+ return store, nil
+ default:
+ return nil, fmt.Errorf("no IAM storer config specified")
+ }
+}
diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go
new file mode 100644
index 00000000..6947cefa
--- /dev/null
+++ b/iamapi/storage/storer_test.go
@@ -0,0 +1,207 @@
+// 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 storage
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/types"
+)
+
+func TestNewRequiresConfig(t *testing.T) {
+ _, err := New(Config{})
+ if err == nil {
+ t.Fatal("New returned nil error without a storer config")
+ }
+ if !strings.Contains(err.Error(), "no IAM storer config specified") {
+ t.Fatalf("error = %q, want missing storer config", err)
+ }
+}
+
+func TestNewCreatesInternalStore(t *testing.T) {
+ dir := t.TempDir()
+
+ _, err := New(Config{Dir: dir})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(dir, "iam.json")); err != nil {
+ t.Fatalf("stat initialized IAM file: %v", err)
+ }
+}
+
+func TestNewRejectsMultipleConfigs(t *testing.T) {
+ _, err := New(Config{
+ Dir: t.TempDir(),
+ Vault: VaultConfig{
+ EndpointURL: "https://vault.example.test",
+ },
+ })
+ if err == nil {
+ t.Fatal("New returned nil error with multiple storer configs")
+ }
+ if !strings.Contains(err.Error(), "multiple IAM storer configs specified") {
+ t.Fatalf("error = %q, want multiple storer configs", err)
+ }
+}
+
+func TestNewVaultRequiresAuth(t *testing.T) {
+ _, err := New(Config{
+ Vault: VaultConfig{
+ EndpointURL: "https://vault.example.test",
+ },
+ })
+ if err == nil {
+ t.Fatal("New returned nil error for vault storer without auth credentials")
+ }
+ if !strings.Contains(err.Error(), "vault authentication requires either roleid/rolesecret or root token") {
+ t.Fatalf("error = %q, want auth required error", err)
+ }
+}
+
+func TestInternalStoreUserCRUDAndPagination(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ store, err := NewInternal(dir)
+ if err != nil {
+ t.Fatalf("NewInternal: %v", err)
+ }
+
+ created := time.Date(2026, 6, 23, 18, 0, 0, 0, time.UTC)
+ users := []types.User{
+ {
+ Path: "/engineering/",
+ UserName: "alice",
+ UserID: "AIDA22222222222222222",
+ Arn: "arn:aws:iam::000000000000:user/engineering/alice",
+ CreateDate: created,
+ Tags: []types.Tag{
+ {Key: "env", Value: "test"},
+ {Key: "empty", Value: ""},
+ },
+ },
+ {
+ Path: "/engineering/platform/",
+ UserName: "bob",
+ UserID: "AIDA33333333333333333",
+ Arn: "arn:aws:iam::000000000000:user/engineering/platform/bob",
+ CreateDate: created.Add(time.Second),
+ },
+ {
+ Path: "/ops/",
+ UserName: "carol",
+ UserID: "AIDA44444444444444444",
+ Arn: "arn:aws:iam::000000000000:user/ops/carol",
+ CreateDate: created.Add(2 * time.Second),
+ },
+ }
+ for _, user := range users {
+ if _, err := store.CreateUser(ctx, user); err != nil {
+ t.Fatalf("CreateUser(%s): %v", user.UserName, err)
+ }
+ }
+
+ if _, err := store.CreateUser(ctx, users[0]); !errors.Is(err, iamerr.EntityAlreadyExistsUser("alice")) {
+ t.Fatalf("CreateUser duplicate err = %v, want EntityAlreadyExists", err)
+ }
+ duplicateID := users[2]
+ duplicateID.UserName = "dave"
+ if _, err := store.CreateUser(ctx, duplicateID); !errors.Is(err, ErrUserIDAlreadyExists) {
+ t.Fatalf("CreateUser duplicate id err = %v, want ErrUserIDAlreadyExists", err)
+ }
+
+ got, err := store.GetUser(ctx, "alice")
+ if err != nil {
+ t.Fatalf("GetUser: %v", err)
+ }
+ if got.UserName != "alice" || got.UserID != users[0].UserID {
+ t.Fatalf("GetUser = %#v, want alice with stable id", got)
+ }
+ if !reflect.DeepEqual(got.Tags, users[0].Tags) {
+ t.Fatalf("GetUser tags = %#v, want %#v", got.Tags, users[0].Tags)
+ }
+
+ page1, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", MaxItems: 1})
+ if err != nil {
+ t.Fatalf("ListUsers page1: %v", err)
+ }
+ if len(page1.Users) != 1 || page1.Users[0].UserName != "alice" || !page1.IsTruncated || page1.Marker != "alice" {
+ t.Fatalf("page1 = %#v, want truncated alice page", page1)
+ }
+ if !reflect.DeepEqual(page1.Users[0].Tags, users[0].Tags) {
+ t.Fatalf("ListUsers tags = %#v, want %#v", page1.Users[0].Tags, users[0].Tags)
+ }
+
+ page2, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10})
+ if err != nil {
+ t.Fatalf("ListUsers page2: %v", err)
+ }
+ if len(page2.Users) != 1 || page2.Users[0].UserName != "bob" || page2.IsTruncated {
+ t.Fatalf("page2 = %#v, want final bob page", page2)
+ }
+
+ updated, err := store.UpdateUser(ctx, UpdateUserInput{
+ UserName: "alice",
+ NewPath: "/ops/",
+ NewUserName: "zoe",
+ NewArn: "arn:aws:iam::000000000000:user/ops/zoe",
+ })
+ if err != nil {
+ t.Fatalf("UpdateUser: %v", err)
+ }
+ if updated.UserName != "zoe" || updated.Path != "/ops/" || updated.Arn != "arn:aws:iam::000000000000:user/ops/zoe" {
+ t.Fatalf("updated = %#v, want renamed/path-updated user", updated)
+ }
+ if updated.UserID != users[0].UserID || !updated.CreateDate.Equal(users[0].CreateDate) {
+ t.Fatalf("updated identity changed: %#v", updated)
+ }
+ if !reflect.DeepEqual(updated.Tags, users[0].Tags) {
+ t.Fatalf("updated tags = %#v, want %#v", updated.Tags, users[0].Tags)
+ }
+ if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) {
+ t.Fatalf("GetUser old name err = %v, want NoSuchEntity", err)
+ }
+ if _, err := store.UpdateUser(ctx, UpdateUserInput{UserName: "zoe", NewUserName: "bob"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("bob")) {
+ t.Fatalf("UpdateUser duplicate err = %v, want EntityAlreadyExists", err)
+ }
+
+ reopened, err := NewInternal(dir)
+ if err != nil {
+ t.Fatalf("reopen NewInternal: %v", err)
+ }
+ reopenedUser, err := reopened.GetUser(ctx, "zoe")
+ if err != nil {
+ t.Fatalf("GetUser after reopen: %v", err)
+ }
+ if !reflect.DeepEqual(reopenedUser.Tags, users[0].Tags) {
+ t.Fatalf("reopened tags = %#v, want %#v", reopenedUser.Tags, users[0].Tags)
+ }
+
+ if err := reopened.DeleteUser(ctx, "zoe"); err != nil {
+ t.Fatalf("DeleteUser: %v", err)
+ }
+ if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.NoSuchEntityUser("zoe")) {
+ t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err)
+ }
+}
diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go
new file mode 100644
index 00000000..de97762b
--- /dev/null
+++ b/iamapi/storage/vault.go
@@ -0,0 +1,435 @@
+// 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 storage
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ vault "github.com/hashicorp/vault-client-go"
+ "github.com/hashicorp/vault-client-go/schema"
+ "github.com/versity/versitygw/iamapi/iamerr"
+ "github.com/versity/versitygw/iamapi/types"
+)
+
+const vaultRequestTimeout = 10 * time.Second
+
+// VaultConfig holds all configuration options for the Vault-backed IAM storer.
+type VaultConfig struct {
+ EndpointURL string
+ Namespace string
+ SecretStoragePath string
+ SecretStorageNamespace string
+ AuthMethod string
+ AuthNamespace string
+ MountPath string
+ RootToken string
+ RoleID string
+ RoleSecret string
+ ServerCert string
+ ClientCert string
+ ClientCertKey string
+}
+
+// VaultStore is a Vault KV v2-backed implementation of Storer.
+type VaultStore struct {
+ client *vault.Client
+ authReqOpts []vault.RequestOption
+ kvReqOpts []vault.RequestOption
+ secretStoragePath string
+ creds schema.AppRoleLoginRequest
+}
+
+var _ Storer = (*VaultStore)(nil)
+
+func NewVault(cfg VaultConfig) (Storer, error) {
+ opts := []vault.ClientOption{
+ vault.WithAddress(strings.TrimSpace(cfg.EndpointURL)),
+ vault.WithRequestTimeout(vaultRequestTimeout),
+ }
+
+ serverCert := strings.TrimSpace(cfg.ServerCert)
+ clientCert := strings.TrimSpace(cfg.ClientCert)
+ clientCertKey := strings.TrimSpace(cfg.ClientCertKey)
+
+ if serverCert != "" {
+ tls := vault.TLSConfiguration{}
+ tls.ServerCertificate.FromBytes = []byte(serverCert)
+ if clientCert != "" {
+ if clientCertKey == "" {
+ return nil, fmt.Errorf("client certificate and client certificate key should both be specified")
+ }
+ tls.ClientCertificate.FromBytes = []byte(clientCert)
+ tls.ClientCertificateKey.FromBytes = []byte(clientCertKey)
+ }
+ opts = append(opts, vault.WithTLS(tls))
+ }
+
+ client, err := vault.New(opts...)
+ if err != nil {
+ return nil, fmt.Errorf("init vault client: %w", err)
+ }
+
+ authMethod := strings.TrimSpace(cfg.AuthMethod)
+ mountPath := strings.TrimSpace(cfg.MountPath)
+
+ authReqOpts := []vault.RequestOption{}
+ if authMethod != "" {
+ authReqOpts = append(authReqOpts, vault.WithMountPath(authMethod))
+ }
+
+ kvReqOpts := []vault.RequestOption{}
+ if mountPath != "" {
+ kvReqOpts = append(kvReqOpts, vault.WithMountPath(mountPath))
+ }
+
+ // Resolve namespaces: specific namespace overrides the generic fallback.
+ authNS := strings.TrimSpace(cfg.AuthNamespace)
+ secretNS := strings.TrimSpace(cfg.SecretStorageNamespace)
+ fallback := strings.TrimSpace(cfg.Namespace)
+ if authNS == "" {
+ authNS = fallback
+ }
+ if secretNS == "" {
+ secretNS = fallback
+ }
+
+ rootToken := strings.TrimSpace(cfg.RootToken)
+ roleID := strings.TrimSpace(cfg.RoleID)
+ roleSecret := strings.TrimSpace(cfg.RoleSecret)
+
+ // AppRole tokens are namespace-scoped; cross-namespace use requires a root token.
+ if rootToken == "" && authNS != "" && secretNS != "" && authNS != secretNS {
+ return nil, fmt.Errorf(
+ "approle tokens are namespace scoped. auth namespace %q and secret storage namespace %q differ. "+
+ "use the same namespace or authenticate with a root token",
+ authNS, secretNS,
+ )
+ }
+
+ if rootToken == "" && authNS != "" {
+ authReqOpts = append(authReqOpts, vault.WithNamespace(authNS))
+ }
+ if secretNS != "" {
+ kvReqOpts = append(kvReqOpts, vault.WithNamespace(secretNS))
+ }
+
+ creds := schema.AppRoleLoginRequest{
+ RoleId: roleID,
+ SecretId: roleSecret,
+ }
+
+ switch {
+ case rootToken != "":
+ if err := client.SetToken(rootToken); err != nil {
+ return nil, fmt.Errorf("root token authentication failure: %w", err)
+ }
+ case roleID != "":
+ if roleSecret == "" {
+ return nil, fmt.Errorf("role id and role secret must both be specified")
+ }
+ resp, err := client.Auth.AppRoleLogin(context.Background(), creds, authReqOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("approle authentication failure: %w", err)
+ }
+ if err := client.SetToken(resp.Auth.ClientToken); err != nil {
+ return nil, fmt.Errorf("approle authentication set token failure: %w", err)
+ }
+ default:
+ return nil, fmt.Errorf("vault authentication requires either roleid/rolesecret or root token")
+ }
+
+ secretStoragePath := strings.TrimSpace(cfg.SecretStoragePath)
+ if secretStoragePath == "" {
+ secretStoragePath = "iam"
+ }
+
+ return &VaultStore{
+ client: client,
+ authReqOpts: authReqOpts,
+ kvReqOpts: kvReqOpts,
+ secretStoragePath: secretStoragePath,
+ creds: creds,
+ }, nil
+}
+
+// reAuthIfNeeded attempts AppRole re-authentication when vault returns 403.
+// It returns nil only when the original error was nil or re-auth succeeded.
+func (s *VaultStore) reAuthIfNeeded(err error) error {
+ if err == nil {
+ return nil
+ }
+ if !vault.IsErrorStatus(err, http.StatusForbidden) {
+ return err
+ }
+ resp, authErr := s.client.Auth.AppRoleLogin(context.Background(), s.creds, s.authReqOpts...)
+ if authErr != nil {
+ return fmt.Errorf("vault re-authentication failure: %w", authErr)
+ }
+ if err := s.client.SetToken(resp.Auth.ClientToken); err != nil {
+ return fmt.Errorf("vault re-authentication set token failure: %w", err)
+ }
+ return nil
+}
+
+func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) {
+ userMap, err := userToVaultMap(user)
+ if err != nil {
+ return nil, fmt.Errorf("serialize user: %w", err)
+ }
+
+ path := s.secretStoragePath + "/" + user.UserName
+ req := schema.KvV2WriteRequest{
+ Data: map[string]any{user.UserName: userMap},
+ Options: map[string]any{
+ "cas": 0,
+ },
+ }
+
+ _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
+ if err != nil {
+ if strings.Contains(err.Error(), "check-and-set") {
+ return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
+ }
+ if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
+ return nil, reauthErr
+ }
+ // retry once after re-auth
+ _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
+ if err != nil {
+ if strings.Contains(err.Error(), "check-and-set") {
+ return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
+ }
+ if vault.IsErrorStatus(err, http.StatusForbidden) {
+ return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err)
+ }
+ return nil, err
+ }
+ }
+ return cloneUser(user), nil
+}
+
+func (s *VaultStore) DeleteUser(ctx context.Context, username string) error {
+ if _, err := s.GetUser(ctx, username); err != nil {
+ return err
+ }
+ return s.deleteByPath(username)
+}
+
+func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) {
+ path := s.secretStoragePath + "/" + username
+ resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
+ if err != nil {
+ if vault.IsErrorStatus(err, http.StatusNotFound) {
+ return nil, iamerr.NoSuchEntityUser(username)
+ }
+ if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
+ return nil, reauthErr
+ }
+ resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
+ if err != nil {
+ if vault.IsErrorStatus(err, http.StatusNotFound) {
+ return nil, iamerr.NoSuchEntityUser(username)
+ }
+ return nil, err
+ }
+ }
+
+ user, err := parseVaultUser(resp.Data.Data, username)
+ if err != nil {
+ return nil, err
+ }
+ return cloneUser(user), nil
+}
+
+func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) {
+ resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...)
+ if err != nil {
+ if vault.IsErrorStatus(err, http.StatusNotFound) {
+ return &ListUsersOutput{Users: []types.User{}}, nil
+ }
+ reauthErr := s.reAuthIfNeeded(err)
+ if reauthErr != nil {
+ if vault.IsErrorStatus(err, http.StatusNotFound) {
+ return &ListUsersOutput{Users: []types.User{}}, nil
+ }
+ return nil, reauthErr
+ }
+ resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...)
+ if err != nil {
+ if vault.IsErrorStatus(err, http.StatusNotFound) {
+ return &ListUsersOutput{Users: []types.User{}}, nil
+ }
+ return nil, err
+ }
+ }
+
+ users := make([]types.User, 0, len(resp.Data.Keys))
+ for _, key := range resp.Data.Keys {
+ user, err := s.GetUser(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if input.PathPrefix != "" && !strings.HasPrefix(user.Path, input.PathPrefix) {
+ continue
+ }
+ users = append(users, *user)
+ }
+
+ sort.Slice(users, func(i, j int) bool {
+ return users[i].UserName < users[j].UserName
+ })
+
+ start := 0
+ if input.Marker != "" {
+ start = len(users)
+ for i, user := range users {
+ if user.UserName == input.Marker {
+ start = i + 1
+ break
+ }
+ }
+ }
+ users = users[start:]
+
+ limit := len(users)
+ if input.MaxItems > 0 && int(input.MaxItems) < limit {
+ limit = int(input.MaxItems)
+ }
+
+ out := &ListUsersOutput{
+ Users: make([]types.User, limit),
+ }
+ copy(out.Users, users[:limit])
+ if limit < len(users) {
+ out.IsTruncated = true
+ out.Marker = out.Users[limit-1].UserName
+ }
+
+ return out, nil
+}
+
+func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) {
+ user, err := s.GetUser(ctx, input.UserName)
+ if err != nil {
+ return nil, err
+ }
+
+ finalName := user.UserName
+ if input.NewUserName != "" {
+ finalName = input.NewUserName
+ }
+
+ if finalName != input.UserName {
+ existing, err := s.GetUser(ctx, finalName)
+ if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) {
+ return nil, err
+ }
+ if existing != nil {
+ return nil, iamerr.EntityAlreadyExistsUser(finalName)
+ }
+ }
+
+ if input.NewPath != "" {
+ user.Path = input.NewPath
+ }
+ if input.NewUserName != "" {
+ user.UserName = input.NewUserName
+ }
+ if input.NewArn != "" {
+ user.Arn = input.NewArn
+ }
+
+ if user.UserName != input.UserName {
+ // Create at new path first to detect conflicts before deleting the old entry.
+ if _, err := s.CreateUser(ctx, *user); err != nil {
+ return nil, err
+ }
+ if err := s.deleteByPath(input.UserName); err != nil {
+ return nil, err
+ }
+ } else {
+ // Delete all versions then re-create so CAS=0 succeeds.
+ if err := s.deleteByPath(input.UserName); err != nil {
+ return nil, err
+ }
+ if _, err := s.CreateUser(ctx, *user); err != nil {
+ return nil, err
+ }
+ }
+
+ return cloneUser(*user), nil
+}
+
+// deleteByPath permanently removes a secret and all its versions without
+// checking for existence first.
+func (s *VaultStore) deleteByPath(username string) error {
+ path := s.secretStoragePath + "/" + username
+ _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
+ if err != nil {
+ if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
+ return reauthErr
+ }
+ _, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine")
+
+// userToVaultMap round-trips User through JSON to produce a map[string]any
+// that vault can store without losing type information on read-back.
+func userToVaultMap(user types.User) (map[string]any, error) {
+ b, err := json.Marshal(user)
+ if err != nil {
+ return nil, err
+ }
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// parseVaultUser reconstructs a User from the raw map[string]any that vault
+// returns. The outer key is the username.
+func parseVaultUser(data map[string]any, username string) (types.User, error) {
+ raw, ok := data[username]
+ if !ok {
+ return types.User{}, errInvalidVaultUser
+ }
+ userMap, ok := raw.(map[string]any)
+ if !ok {
+ return types.User{}, errInvalidVaultUser
+ }
+ b, err := json.Marshal(userMap)
+ if err != nil {
+ return types.User{}, fmt.Errorf("re-marshal vault user: %w", err)
+ }
+ var user types.User
+ if err := json.Unmarshal(b, &user); err != nil {
+ return types.User{}, fmt.Errorf("unmarshal vault user: %w", err)
+ }
+ return user, nil
+}
diff --git a/iamapi/types/user.go b/iamapi/types/user.go
new file mode 100644
index 00000000..40fdc2a8
--- /dev/null
+++ b/iamapi/types/user.go
@@ -0,0 +1,113 @@
+// 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 types
+
+import (
+ "encoding/xml"
+ "time"
+)
+
+type ActionResponse interface {
+ SetRequestID(string)
+}
+
+type ResponseMetadata struct {
+ RequestID string `xml:"RequestId"`
+}
+
+type CreateUserResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
+ Result CreateUserResult `xml:"CreateUserResult"`
+ ResponseMetadata ResponseMetadata
+}
+
+func (r *CreateUserResponse) SetRequestID(requestID string) {
+ r.ResponseMetadata.RequestID = requestID
+}
+
+type CreateUserResult struct {
+ User User
+}
+
+type GetUserResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
+ Result GetUserResult `xml:"GetUserResult"`
+ ResponseMetadata ResponseMetadata
+}
+
+func (r *GetUserResponse) SetRequestID(requestID string) {
+ r.ResponseMetadata.RequestID = requestID
+}
+
+type GetUserResult struct {
+ User User
+}
+
+type ListUsersResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
+ Result ListUsersResult `xml:"ListUsersResult"`
+ ResponseMetadata ResponseMetadata
+}
+
+func (r *ListUsersResponse) SetRequestID(requestID string) {
+ r.ResponseMetadata.RequestID = requestID
+}
+
+type ListUsersResult struct {
+ Users Users
+ IsTruncated bool
+ Marker string `xml:",omitempty"`
+}
+
+type Users struct {
+ Members []User `xml:"member"`
+}
+
+type UpdateUserResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
+ Result UpdateUserResult `xml:"UpdateUserResult"`
+ ResponseMetadata ResponseMetadata
+}
+
+func (r *UpdateUserResponse) SetRequestID(requestID string) {
+ r.ResponseMetadata.RequestID = requestID
+}
+
+type UpdateUserResult struct {
+ User *User
+}
+
+type DeleteUserResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
+ ResponseMetadata ResponseMetadata
+}
+
+func (r *DeleteUserResponse) SetRequestID(requestID string) {
+ r.ResponseMetadata.RequestID = requestID
+}
+
+type User struct {
+ Path string `xml:",omitempty"`
+ UserName string `xml:",omitempty"`
+ UserID string `xml:"UserId"`
+ Arn string `xml:"Arn"`
+ CreateDate time.Time `xml:"CreateDate"`
+ Tags []Tag `xml:"Tags>member,omitempty"`
+}
+
+type Tag struct {
+ Key string
+ Value string
+}
diff --git a/internal/httpctx/context_keys.go b/internal/httpctx/context_keys.go
new file mode 100644
index 00000000..4c7fba7f
--- /dev/null
+++ b/internal/httpctx/context_keys.go
@@ -0,0 +1,56 @@
+// 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 httpctx
+
+import "github.com/gofiber/fiber/v3"
+
+// ContextKey names a request-local value stored in fiber.Ctx locals.
+type ContextKey string
+
+const (
+ ContextKeyRegion ContextKey = "region"
+ ContextKeyStartTime ContextKey = "start-time"
+ ContextKeyIsRoot ContextKey = "is-root"
+ ContextKeyRootAccessKey ContextKey = "root-access-key"
+ ContextKeyAccount ContextKey = "account"
+ ContextKeyAuthenticated ContextKey = "authenticated"
+ ContextKeyPublicBucket ContextKey = "public-bucket"
+ ContextKeyParsedAcl ContextKey = "parsed-acl"
+ ContextKeySkipResBodyLog ContextKey = "skip-res-body-log"
+ ContextKeyBodyReader ContextKey = "body-reader"
+ ContextKeySkip ContextKey = "__skip"
+ ContextKeyStack ContextKey = "stack"
+ ContextKeyBucketOwner ContextKey = "bucket-owner"
+ ContextKeyObjectPostResult ContextKey = "object-post-result"
+ ContextKeyRequestID ContextKey = "request-id"
+ ContextKeyHostID ContextKey = "host-id"
+ ContextKeyWebsiteConfig ContextKey = "website-config"
+)
+
+func (ck ContextKey) Set(ctx fiber.Ctx, val any) {
+ ctx.Locals(string(ck), val)
+}
+
+func (ck ContextKey) IsSet(ctx fiber.Ctx) bool {
+ return ctx.Locals(string(ck)) != nil
+}
+
+func (ck ContextKey) Delete(ctx fiber.Ctx) {
+ ctx.Locals(string(ck), nil)
+}
+
+func (ck ContextKey) Get(ctx fiber.Ctx) any {
+ return ctx.Locals(string(ck))
+}
diff --git a/internal/iamstore/engine.go b/internal/iamstore/engine.go
new file mode 100644
index 00000000..e26758b2
--- /dev/null
+++ b/internal/iamstore/engine.go
@@ -0,0 +1,194 @@
+// 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 iamstore
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ iamMode = 0600
+ backoff = 100 * time.Millisecond
+ maxretry = 300
+)
+
+// UpdateFunc accepts the current JSON data and returns the new JSON data to store.
+type UpdateFunc func([]byte) ([]byte, error)
+
+type NormalizeFunc[T any] func(*T)
+
+type Engine[T any] struct {
+ dir string
+ iamFile string
+ iamBackupFile string
+ defaultConfig T
+ normalize NormalizeFunc[T]
+}
+
+func New[T any](dir, iamFile, iamBackupFile string, defaultConfig T, normalize NormalizeFunc[T]) (*Engine[T], error) {
+ engine := &Engine[T]{
+ dir: dir,
+ iamFile: iamFile,
+ iamBackupFile: iamBackupFile,
+ defaultConfig: defaultConfig,
+ normalize: normalize,
+ }
+
+ if err := engine.InitIAM(); err != nil {
+ return nil, err
+ }
+
+ return engine, nil
+}
+
+func (e *Engine[T]) InitIAM() error {
+ fname := filepath.Join(e.dir, e.iamFile)
+
+ _, err := os.ReadFile(fname)
+ if errors.Is(err, fs.ErrNotExist) {
+ b, err := json.Marshal(e.defaultConfig)
+ if err != nil {
+ return fmt.Errorf("marshal default iam: %w", err)
+ }
+ err = os.WriteFile(fname, b, iamMode)
+ if err != nil {
+ return fmt.Errorf("write default iam: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (e *Engine[T]) GetIAM() (T, error) {
+ b, err := e.ReadIAMData()
+ if err != nil {
+ var zero T
+ return zero, err
+ }
+
+ return e.ParseIAM(b)
+}
+
+func (e *Engine[T]) ParseIAM(b []byte) (T, error) {
+ return ParseIAM(b, e.normalize)
+}
+
+func ParseIAM[T any](b []byte, normalize NormalizeFunc[T]) (T, error) {
+ var conf T
+ if err := json.Unmarshal(b, &conf); err != nil {
+ return conf, fmt.Errorf("failed to parse the config file: %w", err)
+ }
+
+ if normalize != nil {
+ normalize(&conf)
+ }
+
+ return conf, nil
+}
+
+func (e *Engine[T]) ReadIAMData() ([]byte, error) {
+ // We are going to be racing with other running gateways without any
+ // coordination. So we might find the file does not exist at times.
+ // For this case we need to retry for a while assuming the other gateway
+ // will eventually write the file. If it doesn't after the max retries,
+ // then we will return the error.
+
+ retries := 0
+
+ for {
+ b, err := os.ReadFile(filepath.Join(e.dir, e.iamFile))
+ if errors.Is(err, fs.ErrNotExist) {
+ // racing with someone else updating
+ // keep retrying after backoff
+ retries++
+ if retries < maxretry {
+ time.Sleep(backoff)
+ continue
+ }
+ return nil, fmt.Errorf("read iam file: %w", err)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return b, nil
+ }
+}
+
+func (e *Engine[T]) StoreIAM(update UpdateFunc) error {
+ // We are going to be racing with other running gateways without any
+ // coordination. So the strategy here is to read the current file data,
+ // update the data, write back out to a temp file, then rename the
+ // temp file to the original file. This rename will replace the
+ // original file with the new file. This is atomic and should always
+ // allow for a consistent view of the data. There is a small
+ // window where the file could be read and then updated by
+ // another process. In this case any updates the other process did
+ // will be lost. This is a limitation of the internal IAM service.
+ // This should be rare, and even when it does happen should result
+ // in a valid IAM file, just without the other process's updates.
+
+ iamFname := filepath.Join(e.dir, e.iamFile)
+ backupFname := filepath.Join(e.dir, e.iamBackupFile)
+
+ b, err := os.ReadFile(iamFname)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("read iam file: %w", err)
+ }
+
+ err = e.writeUsingTempFile(b, backupFname)
+ if err != nil {
+ return fmt.Errorf("write backup iam file: %w", err)
+ }
+
+ b, err = update(b)
+ if err != nil {
+ return fmt.Errorf("update iam data: %w", err)
+ }
+
+ err = e.writeUsingTempFile(b, iamFname)
+ if err != nil {
+ return fmt.Errorf("write iam file: %w", err)
+ }
+
+ return nil
+}
+
+func (e *Engine[T]) writeUsingTempFile(b []byte, fname string) error {
+ f, err := os.CreateTemp(e.dir, e.iamFile)
+ if err != nil {
+ return fmt.Errorf("create temp file: %w", err)
+ }
+ defer os.Remove(f.Name())
+
+ _, err = f.Write(b)
+ f.Close()
+ if err != nil {
+ return fmt.Errorf("write temp file: %w", err)
+ }
+
+ err = os.Rename(f.Name(), fname)
+ if err != nil {
+ return fmt.Errorf("rename temp file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/iamstore/engine_test.go b/internal/iamstore/engine_test.go
new file mode 100644
index 00000000..de89677b
--- /dev/null
+++ b/internal/iamstore/engine_test.go
@@ -0,0 +1,71 @@
+// 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 iamstore
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type testConfig struct {
+ Users map[string]string `json:"users"`
+}
+
+func TestEngineInitializesReadsParsesAndStoresJSON(t *testing.T) {
+ dir := t.TempDir()
+
+ engine, err := New(dir, "users.json", "users.json.backup", testConfig{Users: map[string]string{}}, func(conf *testConfig) {
+ if conf.Users == nil {
+ conf.Users = map[string]string{}
+ }
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ conf, err := engine.GetIAM()
+ if err != nil {
+ t.Fatalf("GetIAM: %v", err)
+ }
+ if conf.Users == nil {
+ t.Fatal("GetIAM returned nil Users map")
+ }
+
+ err = engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := engine.ParseIAM(data)
+ if err != nil {
+ return nil, err
+ }
+ conf.Users["alice"] = "created"
+ return json.Marshal(conf)
+ })
+ if err != nil {
+ t.Fatalf("StoreIAM: %v", err)
+ }
+
+ conf, err = engine.GetIAM()
+ if err != nil {
+ t.Fatalf("GetIAM after store: %v", err)
+ }
+ if conf.Users["alice"] != "created" {
+ t.Fatalf("stored user = %q, want created", conf.Users["alice"])
+ }
+
+ if _, err := os.Stat(filepath.Join(dir, "users.json.backup")); err != nil {
+ t.Fatalf("stat backup file: %v", err)
+ }
+}
diff --git a/internal/netutil/cert.go b/internal/netutil/cert.go
new file mode 100644
index 00000000..c9748c65
--- /dev/null
+++ b/internal/netutil/cert.go
@@ -0,0 +1,44 @@
+// 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 netutil
+
+import (
+ "crypto/tls"
+ "fmt"
+ "sync/atomic"
+)
+
+type CertStorage struct {
+ cert atomic.Pointer[tls.Certificate]
+}
+
+func NewCertStorage() *CertStorage {
+ return &CertStorage{}
+}
+
+func (cs *CertStorage) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return cs.cert.Load(), nil
+}
+
+func (cs *CertStorage) SetCertificate(certFile string, keyFile string) error {
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return fmt.Errorf("unable to set certificate: %w", err)
+ }
+
+ cs.cert.Store(&cert)
+
+ return nil
+}
diff --git a/internal/netutil/multi_listener.go b/internal/netutil/multi_listener.go
new file mode 100644
index 00000000..7affd504
--- /dev/null
+++ b/internal/netutil/multi_listener.go
@@ -0,0 +1,316 @@
+// 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 netutil
+
+import (
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+// MultiListener implements net.Listener and accepts connections from multiple
+// underlying listeners.
+type MultiListener struct {
+ listeners []net.Listener
+ acceptCh chan acceptResult
+ closeCh chan struct{}
+ closeOnce sync.Once
+ wg sync.WaitGroup
+}
+
+type acceptResult struct {
+ conn net.Conn
+ err error
+}
+
+func NewMultiListener(listeners ...net.Listener) *MultiListener {
+ if len(listeners) == 0 {
+ return nil
+ }
+
+ ml := &MultiListener{
+ listeners: listeners,
+ acceptCh: make(chan acceptResult, 2*len(listeners)),
+ closeCh: make(chan struct{}),
+ }
+
+ for _, ln := range listeners {
+ ml.wg.Add(1)
+ go ml.acceptLoop(ln)
+ }
+
+ return ml
+}
+
+func (ml *MultiListener) acceptLoop(ln net.Listener) {
+ defer ml.wg.Done()
+
+ for {
+ conn, err := ln.Accept()
+
+ select {
+ case <-ml.closeCh:
+ if conn != nil {
+ conn.Close()
+ }
+ return
+ case ml.acceptCh <- acceptResult{conn: conn, err: err}:
+ if err != nil {
+ return
+ }
+ }
+ }
+}
+
+func (ml *MultiListener) Accept() (net.Conn, error) {
+ select {
+ case <-ml.closeCh:
+ return nil, errors.New("listener closed")
+ case result, ok := <-ml.acceptCh:
+ if !ok {
+ return nil, errors.New("listener closed")
+ }
+ return result.conn, result.err
+ }
+}
+
+func (ml *MultiListener) Close() error {
+ var errs []error
+
+ ml.closeOnce.Do(func() {
+ close(ml.closeCh)
+
+ for _, ln := range ml.listeners {
+ if err := ln.Close(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ ml.wg.Wait()
+
+ close(ml.acceptCh)
+ for range ml.acceptCh {
+ }
+ })
+
+ if len(errs) > 0 {
+ return fmt.Errorf("errors closing listeners: %v", errs)
+ }
+ return nil
+}
+
+func (ml *MultiListener) Addr() net.Addr {
+ if len(ml.listeners) > 0 {
+ return ml.listeners[0].Addr()
+ }
+ return nil
+}
+
+func IsUnixSocketPath(addr string) bool {
+ _, _, err := net.SplitHostPort(addr)
+ return err != nil
+}
+
+func AbsSocketPaths(addrs []string) ([]string, error) {
+ result := make([]string, len(addrs))
+ for i, addr := range addrs {
+ if strings.HasPrefix(addr, "./") {
+ abs, err := filepath.Abs(addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve socket path %q: %w", addr, err)
+ }
+ result[i] = abs
+ } else {
+ result[i] = addr
+ }
+ }
+ return result, nil
+}
+
+func isAbstractSocket(addr string) bool {
+ return strings.HasPrefix(addr, "@")
+}
+
+func removeStaleSocket(path string) error {
+ fi, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("failed to stat socket path %q: %w", path, err)
+ }
+ if fi.Mode()&os.ModeSocket == 0 {
+ return fmt.Errorf("path %q already exists and is not a socket (mode %s)", path, fi.Mode())
+ }
+ return os.Remove(path)
+}
+
+func ResolveHostnameIPs(address string) ([]string, error) {
+ if IsUnixSocketPath(address) {
+ return []string{address}, nil
+ }
+
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("invalid address %q: %w", address, err)
+ }
+
+ if host == "" {
+ return []string{""}, nil
+ }
+
+ if net.ParseIP(host) != nil {
+ return []string{host}, nil
+ }
+
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
+ }
+ if len(ips) == 0 {
+ return nil, fmt.Errorf("no addresses found for hostname %q", host)
+ }
+
+ result := make([]string, 0, len(ips))
+ for _, ip := range ips {
+ result = append(result, ip.String())
+ }
+
+ return result, nil
+}
+
+func resolveHostnameAddrs(address string) ([]string, error) {
+ if IsUnixSocketPath(address) {
+ return []string{address}, nil
+ }
+
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("invalid address %q: %w", address, err)
+ }
+
+ if host == "" || net.ParseIP(host) != nil {
+ return []string{address}, nil
+ }
+
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
+ }
+ if len(ips) == 0 {
+ return nil, fmt.Errorf("no addresses found for hostname %q", host)
+ }
+
+ addrs := make([]string, 0, len(ips))
+ for _, ip := range ips {
+ addrs = append(addrs, net.JoinHostPort(ip.String(), port))
+ }
+
+ return addrs, nil
+}
+
+type ListenerOptions struct {
+ SocketPerm os.FileMode
+}
+
+func NewMultiAddrListener(network, address string, opts ListenerOptions) (net.Listener, error) {
+ if IsUnixSocketPath(address) {
+ if !isAbstractSocket(address) {
+ if err := removeStaleSocket(address); err != nil {
+ return nil, err
+ }
+ }
+ ln, err := net.Listen("unix", address)
+ if err != nil {
+ return nil, fmt.Errorf("failed to bind unix socket listener %s: %w", address, err)
+ }
+ if opts.SocketPerm != 0 && !isAbstractSocket(address) {
+ if err := os.Chmod(address, opts.SocketPerm); err != nil {
+ ln.Close()
+ return nil, fmt.Errorf("failed to set permissions on socket %s: %w", address, err)
+ }
+ }
+ return NewMultiListener(ln), nil
+ }
+
+ addrs, err := resolveHostnameAddrs(address)
+ if err != nil {
+ return nil, err
+ }
+
+ listeners := make([]net.Listener, 0, len(addrs))
+ for _, addr := range addrs {
+ ln, err := net.Listen(network, addr)
+ if err != nil {
+ for _, l := range listeners {
+ l.Close()
+ }
+ return nil, fmt.Errorf("failed to bind listener %s: %w", addr, err)
+ }
+ listeners = append(listeners, ln)
+ }
+
+ return NewMultiListener(listeners...), nil
+}
+
+func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error), opts ListenerOptions) (net.Listener, error) {
+ config := &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ GetCertificate: getCertificateFunc,
+ }
+
+ if IsUnixSocketPath(address) {
+ if !isAbstractSocket(address) {
+ if err := removeStaleSocket(address); err != nil {
+ return nil, err
+ }
+ }
+ ln, err := net.Listen("unix", address)
+ if err != nil {
+ return nil, fmt.Errorf("failed to bind unix TLS socket listener %s: %w", address, err)
+ }
+ if opts.SocketPerm != 0 && !isAbstractSocket(address) {
+ if err := os.Chmod(address, opts.SocketPerm); err != nil {
+ ln.Close()
+ return nil, fmt.Errorf("failed to set permissions on socket %s: %w", address, err)
+ }
+ }
+ return NewMultiListener(tls.NewListener(ln, config)), nil
+ }
+
+ addrs, err := resolveHostnameAddrs(address)
+ if err != nil {
+ return nil, err
+ }
+
+ listeners := make([]net.Listener, 0, len(addrs))
+ for _, addr := range addrs {
+ ln, err := net.Listen(network, addr)
+ if err != nil {
+ for _, l := range listeners {
+ l.Close()
+ }
+ return nil, fmt.Errorf("failed to bind TLS listener %s: %w", addr, err)
+ }
+ listeners = append(listeners, tls.NewListener(ln, config))
+ }
+
+ return NewMultiListener(listeners...), nil
+}
diff --git a/internal/sigv4auth/auth.go b/internal/sigv4auth/auth.go
new file mode 100644
index 00000000..73c54790
--- /dev/null
+++ b/internal/sigv4auth/auth.go
@@ -0,0 +1,231 @@
+// 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 sigv4auth
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+)
+
+const (
+ AlgorithmHMACSHA256 = "AWS4-HMAC-SHA256"
+ Terminal = "aws4_request"
+ ServiceS3 = "s3"
+ ServiceIAM = "iam"
+
+ ISO8601Format = "20060102T150405Z"
+ YYYYMMDD = "20060102"
+)
+
+type ParseErrorKind string
+
+const (
+ ErrInvalidAuthorizationHeader ParseErrorKind = "invalid_authorization_header"
+ ErrUnsupportedAuthorizationVersion ParseErrorKind = "unsupported_authorization_version"
+ ErrInvalidAuthorizationType ParseErrorKind = "invalid_authorization_type"
+ ErrMissingComponents ParseErrorKind = "missing_components"
+ ErrMissingCredential ParseErrorKind = "missing_credential"
+ ErrMissingSignedHeaders ParseErrorKind = "missing_signed_headers"
+ ErrMissingSignature ParseErrorKind = "missing_signature"
+ ErrMalformedComponent ParseErrorKind = "malformed_component"
+ ErrMalformedCredential ParseErrorKind = "malformed_credential"
+ ErrIncorrectService ParseErrorKind = "incorrect_service"
+ ErrIncorrectTerminal ParseErrorKind = "incorrect_terminal"
+ ErrInvalidDateFormat ParseErrorKind = "invalid_date_format"
+)
+
+type ParseError struct {
+ Kind ParseErrorKind
+ Input string
+ Value string
+ Expected string
+ Actual string
+}
+
+func (e *ParseError) Error() string {
+ if e == nil {
+ return ""
+ }
+ switch e.Kind {
+ case ErrIncorrectService, ErrIncorrectTerminal:
+ return fmt.Sprintf("sigv4 %s: expected %q, got %q", e.Kind, e.Expected, e.Actual)
+ case ErrInvalidAuthorizationType, ErrMalformedComponent, ErrInvalidDateFormat:
+ return fmt.Sprintf("sigv4 %s: %q", e.Kind, e.Value)
+ default:
+ return string(e.Kind)
+ }
+}
+
+// AuthData is the parsed authorization data from an AWS SigV4 Authorization header.
+type AuthData struct {
+ Algorithm string
+ Access string
+ Region string
+ Service string
+ SignedHeaders string
+ Signature string
+ Date string
+}
+
+type CredentialsScope struct {
+ Access string
+ Date string
+ Region string
+ Service string
+}
+
+// HexBytes returns the hex byte representation used by AWS-style diagnostic
+// signature mismatch errors.
+func HexBytes(s string) string {
+ b := []byte(s)
+
+ parts := make([]string, len(b))
+ for i, v := range b {
+ parts[i] = fmt.Sprintf("%02x", v)
+ }
+
+ return strings.Join(parts, " ")
+}
+
+func PayloadSHA256Hex(payload []byte) string {
+ hashedPayload := sha256.Sum256(payload)
+ return hex.EncodeToString(hashedPayload[:])
+}
+
+// ParseAuthorization parses and validates an AWS SigV4 Authorization header.
+// The credential scope service must match expectedService.
+func ParseAuthorization(authorization, expectedService string) (AuthData, error) {
+ a := AuthData{}
+
+ authParts := strings.SplitN(authorization, " ", 2)
+ for i, el := range authParts {
+ if strings.Contains(el, " ") {
+ authParts[i] = removeSpace(el)
+ }
+ }
+
+ if len(authParts) < 2 {
+ return a, &ParseError{Kind: ErrInvalidAuthorizationHeader, Input: authorization}
+ }
+
+ algo := authParts[0]
+ if algo == "AWS" {
+ return a, &ParseError{Kind: ErrUnsupportedAuthorizationVersion, Value: algo}
+ }
+ if algo != AlgorithmHMACSHA256 {
+ return a, &ParseError{Kind: ErrInvalidAuthorizationType, Value: algo}
+ }
+
+ kvPairs := strings.Split(authParts[1], ",")
+ if len(kvPairs) != 3 {
+ return a, &ParseError{Kind: ErrMissingComponents, Input: authorization}
+ }
+
+ var access, region, service, signedHeaders, signature, date string
+ for i, kv := range kvPairs {
+ keyValue := strings.Split(kv, "=")
+ if len(keyValue) != 2 {
+ return a, &ParseError{Kind: ErrMalformedComponent, Value: kv}
+ }
+ key, value := keyValue[0], keyValue[1]
+ switch i {
+ case 0:
+ if key != "Credential" {
+ return a, &ParseError{Kind: ErrMissingCredential}
+ }
+ case 1:
+ if key != "SignedHeaders" {
+ return a, &ParseError{Kind: ErrMissingSignedHeaders}
+ }
+ case 2:
+ if key != "Signature" {
+ return a, &ParseError{Kind: ErrMissingSignature}
+ }
+ }
+
+ switch key {
+ case "Credential":
+ creds, err := ParseCredentials(value, expectedService)
+ if err != nil {
+ return a, err
+ }
+ access = creds.Access
+ date = creds.Date
+ region = creds.Region
+ service = creds.Service
+ case "SignedHeaders":
+ signedHeaders = value
+ case "Signature":
+ signature = value
+ }
+ }
+
+ return AuthData{
+ Algorithm: algo,
+ Access: access,
+ Region: region,
+ Service: service,
+ SignedHeaders: signedHeaders,
+ Signature: signature,
+ Date: date,
+ }, nil
+}
+
+func ParseCredentials(input, expectedService string) (*CredentialsScope, error) {
+ creds := strings.Split(input, "/")
+ if len(creds) != 5 {
+ return nil, &ParseError{Kind: ErrMalformedCredential, Input: input}
+ }
+ if creds[3] != expectedService {
+ return nil, &ParseError{
+ Kind: ErrIncorrectService,
+ Input: input,
+ Expected: expectedService,
+ Actual: creds[3],
+ }
+ }
+ if creds[4] != Terminal {
+ return nil, &ParseError{
+ Kind: ErrIncorrectTerminal,
+ Input: input,
+ Expected: Terminal,
+ Actual: creds[4],
+ }
+ }
+ if _, err := time.Parse(YYYYMMDD, creds[1]); err != nil {
+ return nil, &ParseError{Kind: ErrInvalidDateFormat, Input: input, Value: creds[1]}
+ }
+
+ return &CredentialsScope{
+ Access: creds[0],
+ Date: creds[1],
+ Region: creds[2],
+ Service: creds[3],
+ }, nil
+}
+
+func removeSpace(str string) string {
+ var b strings.Builder
+ b.Grow(len(str))
+ for _, ch := range str {
+ if !unicode.IsSpace(ch) {
+ b.WriteRune(ch)
+ }
+ }
+ return b.String()
+}
diff --git a/internal/sigv4auth/query.go b/internal/sigv4auth/query.go
new file mode 100644
index 00000000..5fd2da5c
--- /dev/null
+++ b/internal/sigv4auth/query.go
@@ -0,0 +1,406 @@
+// 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 sigv4auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/smithy-go/logging"
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/aws/signer/v4"
+ "github.com/versity/versitygw/debuglogger"
+)
+
+const (
+ AlgorithmECDSAP256SHA256 = "AWS4-ECDSA-P256-SHA256"
+
+ QueryAlgorithm = "X-Amz-Algorithm"
+ QueryCredential = "X-Amz-Credential"
+ QueryDate = "X-Amz-Date"
+ QueryExpires = "X-Amz-Expires"
+ QuerySignedHeaders = "X-Amz-SignedHeaders"
+ QuerySignature = "X-Amz-Signature"
+ QuerySecurityToken = "X-Amz-Security-Token"
+
+ maxQueryExpirationSeconds = 604800
+)
+
+type QueryErrorKind string
+
+const (
+ ErrQueryMissingRequiredParams QueryErrorKind = "missing_required_query_parameters"
+ ErrQueryUnsupportedAlgorithm QueryErrorKind = "unsupported_query_algorithm"
+ ErrQueryUnsupportedECDSA QueryErrorKind = "unsupported_query_ecdsa"
+ ErrQueryInvalidDateFormat QueryErrorKind = "invalid_query_date_format"
+ ErrQueryDateMismatch QueryErrorKind = "query_date_mismatch"
+ ErrQueryIncorrectRegion QueryErrorKind = "query_incorrect_region"
+ ErrQueryExpiresNumber QueryErrorKind = "query_expires_number"
+ ErrQueryExpiresNegative QueryErrorKind = "query_expires_negative"
+ ErrQueryExpiresTooLarge QueryErrorKind = "query_expires_too_large"
+ ErrQueryExpired QueryErrorKind = "query_expired"
+ ErrQuerySecurityToken QueryErrorKind = "query_security_token"
+)
+
+type QueryError struct {
+ Kind QueryErrorKind
+ Value string
+ Expected string
+ Actual string
+ Expires int
+ ExpiresAt time.Time
+ ServerTime time.Time
+}
+
+func (e *QueryError) Error() string {
+ if e == nil {
+ return ""
+ }
+ switch e.Kind {
+ case ErrQueryIncorrectRegion:
+ return fmt.Sprintf("sigv4 query %s: expected %q, got %q", e.Kind, e.Expected, e.Actual)
+ case ErrQueryDateMismatch:
+ return fmt.Sprintf("sigv4 query %s: expected %q, got %q", e.Kind, e.Expected, e.Actual)
+ case ErrQueryExpired:
+ return fmt.Sprintf("sigv4 query %s: expired at %s", e.Kind, e.ExpiresAt.Format(time.RFC3339))
+ case ErrQueryUnsupportedAlgorithm, ErrQueryUnsupportedECDSA, ErrQueryExpiresNumber:
+ return fmt.Sprintf("sigv4 query %s: %q", e.Kind, e.Value)
+ default:
+ return string(e.Kind)
+ }
+}
+
+type QueryAuthOptions struct {
+ Service string
+ Region string
+ // RequireExpiration enables the X-Amz-Expires validation required by S3
+ // presigned URLs. Other SigV4 query-auth services, including IAM, leave it
+ // disabled.
+ RequireExpiration bool
+ Now func() time.Time
+}
+
+type QueryAuthDetails struct {
+ SigningTime time.Time
+ Expires int
+ ExpiresAt time.Time
+ ServerTime time.Time
+}
+
+// ParseQueryAuthorization parses and validates AWS SigV4 query-string
+// authentication parameters. The credential scope service must match
+// opts.Service. If opts.Region is set, the credential scope region must match
+// it as well.
+func ParseQueryAuthorization(ctx fiber.Ctx, opts QueryAuthOptions) (AuthData, QueryAuthDetails, error) {
+ a := AuthData{}
+ details := QueryAuthDetails{}
+
+ if err := ValidateQueryAlgorithm(ctx.Query(QueryAlgorithm)); err != nil {
+ return a, details, err
+ }
+
+ credsQuery := ctx.Query(QueryCredential)
+ if credsQuery == "" {
+ return a, details, missingQueryParameterError(QueryCredential)
+ }
+
+ creds, err := ParseCredentials(credsQuery, opts.Service)
+ if err != nil {
+ return a, details, err
+ }
+
+ if opts.Region != "" && creds.Region != opts.Region {
+ return a, details, &QueryError{
+ Kind: ErrQueryIncorrectRegion,
+ Expected: opts.Region,
+ Actual: creds.Region,
+ }
+ }
+
+ date := ctx.Query(QueryDate)
+ if date == "" {
+ return a, details, missingQueryParameterError(QueryDate)
+ }
+
+ tdate, err := time.Parse(ISO8601Format, date)
+ if err != nil {
+ return a, details, &QueryError{Kind: ErrQueryInvalidDateFormat, Value: date}
+ }
+
+ if date[:8] != creds.Date {
+ return a, details, &QueryError{
+ Kind: ErrQueryDateMismatch,
+ Expected: creds.Date,
+ Actual: date[:8],
+ }
+ }
+
+ signature := ctx.Query(QuerySignature)
+ if signature == "" {
+ return a, details, missingQueryParameterError(QuerySignature)
+ }
+
+ signedHdrs := ctx.Query(QuerySignedHeaders)
+ if signedHdrs == "" {
+ return a, details, missingQueryParameterError(QuerySignedHeaders)
+ }
+
+ expiration := QueryExpiration{}
+ if opts.RequireExpiration {
+ now := time.Now().UTC()
+ if opts.Now != nil {
+ now = opts.Now().UTC()
+ }
+ expiration, err = ValidateQueryExpiration(ctx.Query(QueryExpires), tdate, now)
+ if err != nil {
+ return a, details, err
+ }
+ }
+
+ a = AuthData{
+ Algorithm: ctx.Query(QueryAlgorithm),
+ Access: creds.Access,
+ Region: creds.Region,
+ Service: creds.Service,
+ SignedHeaders: signedHdrs,
+ Signature: signature,
+ Date: date,
+ }
+ details = QueryAuthDetails{
+ SigningTime: tdate,
+ Expires: expiration.Expires,
+ ExpiresAt: expiration.ExpiresAt,
+ ServerTime: expiration.ServerTime,
+ }
+
+ return a, details, nil
+}
+
+func ValidateQueryAlgorithm(algo string) error {
+ switch algo {
+ case "":
+ return missingQueryParameterError(QueryAlgorithm)
+ case AlgorithmHMACSHA256:
+ return nil
+ case AlgorithmECDSAP256SHA256:
+ return &QueryError{Kind: ErrQueryUnsupportedECDSA, Value: algo}
+ default:
+ return &QueryError{Kind: ErrQueryUnsupportedAlgorithm, Value: algo}
+ }
+}
+
+type QueryExpiration struct {
+ Expires int
+ ExpiresAt time.Time
+ ServerTime time.Time
+}
+
+func ValidateQueryExpiration(str string, date, now time.Time) (QueryExpiration, error) {
+ if str == "" {
+ return QueryExpiration{}, missingQueryParameterError(QueryExpires)
+ }
+
+ exp, err := strconv.Atoi(str)
+ if err != nil {
+ return QueryExpiration{}, &QueryError{Kind: ErrQueryExpiresNumber, Value: str}
+ }
+
+ if exp < 0 {
+ return QueryExpiration{}, &QueryError{Kind: ErrQueryExpiresNegative, Value: str}
+ }
+
+ if exp > maxQueryExpirationSeconds {
+ return QueryExpiration{}, &QueryError{Kind: ErrQueryExpiresTooLarge, Value: str}
+ }
+
+ now = now.UTC()
+ expiresAt := date.Add(time.Duration(exp) * time.Second)
+ expiration := QueryExpiration{
+ Expires: exp,
+ ExpiresAt: expiresAt,
+ ServerTime: now,
+ }
+
+ if expiresAt.Before(now) {
+ return expiration, &QueryError{
+ Kind: ErrQueryExpired,
+ Expires: exp,
+ ExpiresAt: expiresAt,
+ ServerTime: now,
+ }
+ }
+
+ return expiration, nil
+}
+
+func missingQueryParameterError(parameter string) *QueryError {
+ return &QueryError{Kind: ErrQueryMissingRequiredParams, Value: parameter}
+}
+
+// CheckQuerySignature rebuilds a SigV4 query-auth request and compares the
+// generated query signature to the signature presented by the client.
+func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, tdate time.Time, contentLen int64, opts CheckOptions) (*CheckResult, error) {
+ service := opts.Service
+ if service == "" {
+ service = auth.Service
+ }
+ signedHdrs := strings.Split(auth.SignedHeaders, ";")
+
+ req, err := createPresignedHTTPRequestFromCtx(ctx, signedHdrs, contentLen, opts.RequiredSignedHeaders)
+ if err != nil {
+ return nil, err
+ }
+
+ signer := v4.NewSigner()
+ uri, _, signMeta, err := signer.PresignHTTP(ctx.RequestCtx(),
+ aws.Credentials{
+ AccessKeyID: auth.Access,
+ SecretAccessKey: secret,
+ },
+ req, payloadHash, service, auth.Region, tdate, signedHdrs,
+ func(options *v4.SignerOptions) {
+ options.DisableURIPathEscaping = opts.DisableURIPathEscaping
+ if debuglogger.IsDebugEnabled() {
+ options.LogSigning = true
+ options.Logger = logging.NewStandardLogger(os.Stderr)
+ }
+ })
+ if err != nil {
+ return nil, fmt.Errorf("presign generated http request: %w", err)
+ }
+
+ urlParts, err := url.Parse(uri)
+ if err != nil {
+ return nil, fmt.Errorf("parse presigned url: %w", err)
+ }
+
+ signature := urlParts.Query().Get(QuerySignature)
+ if signature != auth.Signature {
+ return nil, &SignatureMismatchError{
+ AccessKeyID: auth.Access,
+ StringToSign: signMeta.StringToSign,
+ SignatureProvided: auth.Signature,
+ StringToSignBytes: HexBytes(signMeta.StringToSign),
+ CanonicalRequest: signMeta.CanonicalString,
+ CanonicalRequestBytes: HexBytes(signMeta.CanonicalString),
+ }
+ }
+
+ return &CheckResult{
+ CanonicalString: signMeta.CanonicalString,
+ StringToSign: signMeta.StringToSign,
+ }, nil
+}
+
+var generatedQueryAuthParams = map[string]struct{}{
+ QueryAlgorithm: {},
+ QueryCredential: {},
+ QueryDate: {},
+ QuerySignedHeaders: {},
+ QuerySignature: {},
+}
+
+func createPresignedHTTPRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64, requiredSignedHdrs []string) (*http.Request, error) {
+ req := ctx.Request()
+ if err := validateRequiredSignedHeaders(signedHdrs, requiredSignedHdrs); err != nil {
+ return nil, err
+ }
+
+ uri, _, _ := strings.Cut(ctx.OriginalURL(), "?")
+ query := strings.Builder{}
+
+ for key, value := range ctx.Request().URI().QueryArgs().All() {
+ keyStr := string(key)
+ if _, ok := generatedQueryAuthParams[keyStr]; ok {
+ continue
+ }
+
+ if query.Len() > 0 {
+ query.WriteByte('&')
+ }
+ query.WriteString(url.QueryEscape(keyStr))
+ query.WriteByte('=')
+ query.WriteString(url.QueryEscape(string(value)))
+ }
+
+ if query.Len() > 0 {
+ uri += "?" + query.String()
+ }
+
+ httpReq, err := http.NewRequest(string(req.Header.Method()), uri, nil)
+ if err != nil {
+ return nil, errors.New("error in creating an http request")
+ }
+ if err := addRequestHeadersFromCtx(ctx, httpReq, signedHdrs, requiredSignedHdrs); err != nil {
+ return nil, err
+ }
+
+ if !includeHeader("Content-Length", signedHdrs) {
+ httpReq.ContentLength = 0
+ } else {
+ httpReq.ContentLength = contentLength
+ }
+
+ httpReq.Host = string(req.Header.Host())
+
+ return httpReq, nil
+}
+
+// IsQueryAuth determines if a request uses SigV4 query-string auth.
+func IsQueryAuth(ctx fiber.Ctx) bool {
+ algo := ctx.Query(QueryAlgorithm)
+ creds := ctx.Query(QueryCredential)
+ date := ctx.Query(QueryDate)
+ signature := ctx.Query(QuerySignature)
+ signedHeaders := ctx.Query(QuerySignedHeaders)
+
+ return !allEmpty(algo, creds, date, signature, signedHeaders)
+}
+
+// IsQueryAuthV2 determines if a request is query-string signed with the legacy
+// AWS Signature Version 2 signer.
+func IsQueryAuthV2(ctx fiber.Ctx) bool {
+ expires := ctx.Query("Expires")
+ access := ctx.Query("AWSAccessKeyId")
+ signature := ctx.Query("Signature")
+
+ return anyNonEmpty(expires, access, signature)
+}
+
+func allEmpty(args ...string) bool {
+ for _, a := range args {
+ if a != "" {
+ return false
+ }
+ }
+
+ return true
+}
+
+func anyNonEmpty(args ...string) bool {
+ for _, a := range args {
+ if a != "" {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/sigv4auth/verify.go b/internal/sigv4auth/verify.go
new file mode 100644
index 00000000..08f6c790
--- /dev/null
+++ b/internal/sigv4auth/verify.go
@@ -0,0 +1,213 @@
+// 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 sigv4auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/smithy-go/logging"
+ "github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/aws/signer/v4"
+ "github.com/versity/versitygw/debuglogger"
+)
+
+type CheckOptions struct {
+ Service string
+ DisableURIPathEscaping bool
+ // RequiredSignedHeaders overrides the default AWS signed-header policy.
+ // A nil slice requires every applicable X-Amz-* header to be signed.
+ RequiredSignedHeaders []string
+}
+
+type CheckResult struct {
+ CanonicalString string
+ StringToSign string
+}
+
+type HeadersNotSignedError struct {
+ Headers []string
+}
+
+func (e *HeadersNotSignedError) Error() string {
+ return fmt.Sprintf("headers not signed: %s", strings.Join(e.Headers, ", "))
+}
+
+type SignatureMismatchError struct {
+ AccessKeyID string
+ StringToSign string
+ SignatureProvided string
+ StringToSignBytes string
+ CanonicalRequest string
+ CanonicalRequestBytes string
+}
+
+func (e *SignatureMismatchError) Error() string {
+ return "signature does not match"
+}
+
+// CheckSignature rebuilds the canonical request with the supplied service,
+// region, payload hash, signing time, and signed headers, then compares the
+// generated signature to the signature presented by the client.
+func CheckSignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, tdate time.Time, contentLen int64, opts CheckOptions) (*CheckResult, error) {
+ service := opts.Service
+ if service == "" {
+ service = auth.Service
+ }
+ signedHdrs := strings.Split(auth.SignedHeaders, ";")
+
+ req, err := createHTTPRequestFromCtx(ctx, signedHdrs, contentLen, opts.RequiredSignedHeaders)
+ if err != nil {
+ return nil, err
+ }
+
+ signer := v4.NewSigner()
+
+ signMeta, err := signer.SignHTTP(req.Context(),
+ aws.Credentials{
+ AccessKeyID: auth.Access,
+ SecretAccessKey: secret,
+ },
+ req, payloadHash, service, auth.Region, tdate, signedHdrs,
+ func(options *v4.SignerOptions) {
+ options.DisableURIPathEscaping = opts.DisableURIPathEscaping
+ if debuglogger.IsDebugEnabled() {
+ options.LogSigning = true
+ options.Logger = logging.NewStandardLogger(os.Stderr)
+ }
+ })
+ if err != nil {
+ return nil, fmt.Errorf("sign generated http request: %w", err)
+ }
+
+ genAuth, err := ParseAuthorization(req.Header.Get("Authorization"), service)
+ if err != nil {
+ return nil, err
+ }
+
+ if auth.Signature != genAuth.Signature {
+ return nil, &SignatureMismatchError{
+ AccessKeyID: auth.Access,
+ StringToSign: signMeta.StringToSign,
+ SignatureProvided: auth.Signature,
+ StringToSignBytes: HexBytes(signMeta.StringToSign),
+ CanonicalRequest: signMeta.CanonicalString,
+ CanonicalRequestBytes: HexBytes(signMeta.CanonicalString),
+ }
+ }
+
+ return &CheckResult{
+ CanonicalString: signMeta.CanonicalString,
+ StringToSign: signMeta.StringToSign,
+ }, nil
+}
+
+func CreateHTTPRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64) (*http.Request, error) {
+ return createHTTPRequestFromCtx(ctx, signedHdrs, contentLength, nil)
+}
+
+func createHTTPRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64, requiredSignedHdrs []string) (*http.Request, error) {
+ req := ctx.Request()
+ if err := validateRequiredSignedHeaders(signedHdrs, requiredSignedHdrs); err != nil {
+ return nil, err
+ }
+
+ httpReq, err := http.NewRequest(string(req.Header.Method()), ctx.OriginalURL(), nil)
+ if err != nil {
+ return nil, errors.New("error in creating an http request")
+ }
+
+ if err := addRequestHeadersFromCtx(ctx, httpReq, signedHdrs, requiredSignedHdrs); err != nil {
+ return nil, err
+ }
+
+ for _, header := range signedHdrs {
+ if httpReq.Header.Get(header) == "" {
+ httpReq.Header.Set(header, "")
+ }
+ }
+
+ if !includeHeader("Content-Length", signedHdrs) {
+ httpReq.ContentLength = 0
+ } else {
+ httpReq.ContentLength = contentLength
+ }
+
+ httpReq.Host = string(req.Header.Host())
+
+ return httpReq, nil
+}
+
+func AddRequestHeadersFromCtx(ctx fiber.Ctx, httpReq *http.Request, signedHdrs []string) error {
+ return addRequestHeadersFromCtx(ctx, httpReq, signedHdrs, nil)
+}
+
+func addRequestHeadersFromCtx(ctx fiber.Ctx, httpReq *http.Request, signedHdrs, requiredSignedHdrs []string) error {
+ headersNotSigned := []string{}
+ for key, value := range ctx.Request().Header.All() {
+ keyStr := string(key)
+ if includeHeader(keyStr, signedHdrs) || v4.IsIgnoredHeader(keyStr) {
+ httpReq.Header.Add(keyStr, string(value))
+ continue
+ }
+ if isRequiredSignedHeader(keyStr, requiredSignedHdrs) {
+ headersNotSigned = append(headersNotSigned, strings.ToLower(keyStr))
+ }
+ }
+
+ if len(headersNotSigned) != 0 {
+ debuglogger.Logf("headers present in request but not included in SignedHeaders: %q", strings.Join(headersNotSigned, ", "))
+ return &HeadersNotSignedError{Headers: headersNotSigned}
+ }
+
+ return nil
+}
+
+func validateRequiredSignedHeaders(signedHdrs, requiredSignedHdrs []string) error {
+ if requiredSignedHdrs == nil {
+ return nil
+ }
+
+ headersNotSigned := []string{}
+ for _, header := range requiredSignedHdrs {
+ if !includeHeader(header, signedHdrs) {
+ headersNotSigned = append(headersNotSigned, strings.ToLower(header))
+ }
+ }
+ if len(headersNotSigned) != 0 {
+ return &HeadersNotSignedError{Headers: headersNotSigned}
+ }
+
+ return nil
+}
+
+func isRequiredSignedHeader(header string, requiredSignedHdrs []string) bool {
+ if requiredSignedHdrs == nil {
+ return v4.IsRequiredSignedHeader(header)
+ }
+
+ return includeHeader(header, requiredSignedHdrs)
+}
+
+func includeHeader(hdr string, signedHdrs []string) bool {
+ return slices.ContainsFunc(signedHdrs, func(shdr string) bool {
+ return strings.EqualFold(hdr, shdr)
+ })
+}
diff --git a/runiamtests.sh b/runiamtests.sh
new file mode 100755
index 00000000..73dacc5a
--- /dev/null
+++ b/runiamtests.sh
@@ -0,0 +1,210 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+IAM_PID=""
+IAM_HTTPS_PID=""
+IAM_VAULT_PID=""
+CERT_DIR=""
+
+stop_process() {
+ local pid="${1:-}"
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" 2>/dev/null || true
+ fi
+ if [[ -n "$pid" ]]; then
+ wait "$pid" 2>/dev/null || true
+ fi
+}
+
+cleanup() {
+ local status=$?
+ trap - EXIT
+ stop_process "$IAM_VAULT_PID"
+ stop_process "$IAM_HTTPS_PID"
+ stop_process "$IAM_PID"
+ if [[ -n "$CERT_DIR" ]]; then
+ rm -rf "$CERT_DIR"
+ fi
+ exit "$status"
+}
+
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+wait_for_server() {
+ local name="$1"
+ local url="$2"
+ local pid="$3"
+ shift 3
+
+ for _ in {1..50}; do
+ if curl --fail --silent --max-time 1 "$@" "$url" >/dev/null 2>&1; then
+ return 0
+ fi
+ if ! kill -0 "$pid" 2>/dev/null; then
+ echo "$name stopped before becoming ready" >&2
+ wait "$pid" 2>/dev/null || true
+ return 1
+ fi
+ sleep 0.2
+ done
+
+ echo "timed out waiting for $name at $url" >&2
+ return 1
+}
+
+for tool in curl jq openssl; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ echo "required command not found: $tool" >&2
+ exit 1
+ fi
+done
+
+# Create fresh data and coverage directories for each run.
+rm -rf /tmp/iam /tmp/iam-https \
+ /tmp/iam.covdata /tmp/iam.https.covdata /tmp/iam.vault.covdata
+mkdir -p /tmp/iam /tmp/iam-https \
+ /tmp/iam.covdata /tmp/iam.https.covdata /tmp/iam.vault.covdata
+
+CERT_DIR=$(mktemp -d)
+echo "Generating a temporary TLS certificate"
+openssl genpkey -algorithm RSA -out "$CERT_DIR/key.pem" -pkeyopt rsa_keygen_bits:2048
+openssl req -new -x509 -key "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
+ -days 1 -subj "/C=US/ST=California/L=San Francisco/O=Versity/OU=Software/CN=versity.com"
+
+echo "Running IAM API integration tests over HTTP"
+GOCOVERDIR=/tmp/iam.covdata ./versitygw --health /healthz -p :7075 -a user -s pass \
+ iam --dir /tmp/iam &
+IAM_PID=$!
+wait_for_server "IAM API HTTP server" "http://127.0.0.1:7075/healthz" "$IAM_PID"
+./versitygw test -a user -s pass -e http://127.0.0.1:7075 iam
+stop_process "$IAM_PID"
+IAM_PID=""
+
+echo "Running IAM API integration tests over HTTPS"
+GOCOVERDIR=/tmp/iam.https.covdata ./versitygw --health /healthz \
+ --cert "$CERT_DIR/cert.pem" --key "$CERT_DIR/key.pem" \
+ -p :7076 -a user -s pass iam --dir /tmp/iam-https &
+IAM_HTTPS_PID=$!
+wait_for_server "IAM API HTTPS server" "https://127.0.0.1:7076/healthz" "$IAM_HTTPS_PID" --insecure
+./versitygw test --allow-insecure -a user -s pass -e https://127.0.0.1:7076 iam
+stop_process "$IAM_HTTPS_PID"
+IAM_HTTPS_PID=""
+
+# Vault is provided by the GitHub Actions service container. The root token is
+# used only to provision a least-privilege AppRole for the IAM API under test.
+readonly VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}"
+: "${VAULT_TOKEN:?VAULT_TOKEN must contain a Vault provisioning token}"
+readonly VAULT_PROVISION_TOKEN="$VAULT_TOKEN"
+unset VAULT_TOKEN
+readonly VAULT_MOUNT_PATH="kv"
+readonly VAULT_SECRET_PATH="iam"
+readonly VAULT_POLICY_NAME="iam-api-tests"
+readonly VAULT_ROLE_NAME="iam-api-tests"
+
+vault_request() {
+ local method="$1"
+ local path="$2"
+ local data="${3:-}"
+ local args=(
+ --fail
+ --silent
+ --show-error
+ --request "$method"
+ --header "X-Vault-Token: $VAULT_PROVISION_TOKEN"
+ )
+
+ if [[ -n "$data" ]]; then
+ args+=(--header "Content-Type: application/json" --data "$data")
+ fi
+
+ curl "${args[@]}" "${VAULT_ADDR%/}/v1/$path"
+}
+
+echo "Waiting for Vault"
+for _ in {1..30}; do
+ if curl --fail --silent --max-time 1 "${VAULT_ADDR%/}/v1/sys/health" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 0.5
+done
+curl --fail --silent --show-error "${VAULT_ADDR%/}/v1/sys/health" >/dev/null
+
+echo "Provisioning Vault KV v2 and AppRole"
+vault_mounts=$(vault_request GET sys/mounts)
+if jq -e --arg mount "$VAULT_MOUNT_PATH/" '.data[$mount] == null' <<<"$vault_mounts" >/dev/null; then
+ vault_request POST "sys/mounts/$VAULT_MOUNT_PATH" \
+ '{"type":"kv","options":{"version":"2"}}' >/dev/null
+elif ! jq -e --arg mount "$VAULT_MOUNT_PATH/" \
+ '.data[$mount].type == "kv" and .data[$mount].options.version == "2"' \
+ <<<"$vault_mounts" >/dev/null; then
+ echo "Vault mount $VAULT_MOUNT_PATH exists but is not KV v2" >&2
+ exit 1
+fi
+
+vault_auth_methods=$(vault_request GET sys/auth)
+if jq -e '.data["approle/"] == null' <<<"$vault_auth_methods" >/dev/null; then
+ vault_request POST sys/auth/approle '{"type":"approle"}' >/dev/null
+fi
+
+vault_policy=$(printf '%s\n' \
+ "path \"$VAULT_MOUNT_PATH/data/$VAULT_SECRET_PATH/*\" { capabilities = [\"create\", \"update\", \"read\"] }" \
+ "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/\" { capabilities = [\"list\"] }" \
+ "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\"] }")
+vault_policy_payload=$(jq -nc --arg policy "$vault_policy" '{policy: $policy}')
+vault_request PUT "sys/policies/acl/$VAULT_POLICY_NAME" "$vault_policy_payload" >/dev/null
+
+vault_role_payload=$(jq -nc --arg policy "$VAULT_POLICY_NAME" '{
+ token_policies: [$policy],
+ token_no_default_policy: true,
+ token_ttl: "5m",
+ token_max_ttl: "15m",
+ secret_id_ttl: "15m"
+}')
+vault_request POST "auth/approle/role/$VAULT_ROLE_NAME" "$vault_role_payload" >/dev/null
+
+vault_role_id=$(vault_request GET "auth/approle/role/$VAULT_ROLE_NAME/role-id" | jq -er '.data.role_id')
+vault_role_secret=$(vault_request POST "auth/approle/role/$VAULT_ROLE_NAME/secret-id" | jq -er '.data.secret_id')
+
+echo "Running IAM API integration tests with the Vault backend"
+VGW_IAM_VAULT_ROLE_SECRET="$vault_role_secret" \
+ GOCOVERDIR=/tmp/iam.vault.covdata ./versitygw --health /healthz -p :7077 -a user -s pass iam \
+ --vault-endpoint-url "$VAULT_ADDR" \
+ --vault-auth-method approle \
+ --vault-role-id "$vault_role_id" \
+ --vault-mount-path "$VAULT_MOUNT_PATH" \
+ --vault-secret-storage-path "$VAULT_SECRET_PATH" &
+IAM_VAULT_PID=$!
+wait_for_server "IAM API Vault server" "http://127.0.0.1:7077/healthz" "$IAM_VAULT_PID"
+./versitygw test -a user -s pass -e http://127.0.0.1:7077 iam
+stop_process "$IAM_VAULT_PID"
+IAM_VAULT_PID=""
+
+# -----------------------------------------------------------------------------
+# Coverage Reports (Go 1.20+ Runtime Coverage)
+#
+# The IAM servers above were started with GOCOVERDIR=
, which causes Go to
+# write raw coverage artifacts into these directories:
+#
+# /tmp/iam.covdata
+# /tmp/iam.https.covdata
+# /tmp/iam.vault.covdata
+#
+# Generate individual HTTP, HTTPS, and Vault coverage reports with:
+#
+# go tool covdata percent -i=/tmp/iam.covdata
+# go tool covdata percent -i=/tmp/iam.https.covdata
+# go tool covdata percent -i=/tmp/iam.vault.covdata
+#
+# Generate a merged IAM coverage report with:
+#
+# go tool covdata merge \
+# -i=/tmp/iam.covdata,/tmp/iam.https.covdata,/tmp/iam.vault.covdata \
+# -o /tmp/iam.all.covdata
+#
+# go tool covdata percent -i=/tmp/iam.all.covdata
+# go tool covdata textfmt -i=/tmp/iam.all.covdata -o /tmp/iam_profile.txt
+# go tool cover -html=/tmp/iam_profile.txt
+# -----------------------------------------------------------------------------
diff --git a/runtests.ps1 b/runtests.ps1
index 1650a42b..497e5a68 100644
--- a/runtests.ps1
+++ b/runtests.ps1
@@ -85,7 +85,7 @@ Invoke-GwTest -Description "full flow tests" -GatewayProc $gwProc `
Invoke-GwTest -Description "posix tests" -GatewayProc $gwProc `
-TestArgs @("-a", "user", "-s", "pass", "-e", "http://127.0.0.1:7070", "posix", "--windows-test-mode")
Invoke-GwTest -Description "iam tests" -GatewayProc $gwProc `
- -TestArgs @("-a", "user", "-s", "pass", "-e", "http://127.0.0.1:7070", "iam")
+ -TestArgs @("-a", "user", "-s", "pass", "-e", "http://127.0.0.1:7070", "gw-iam")
Stop-Process -Id $gwProc.Id -Force -ErrorAction SilentlyContinue
@@ -108,7 +108,7 @@ Invoke-GwTest -Description "https full flow tests" -GatewayProc $gwHttpsProc `
Invoke-GwTest -Description "https posix tests" -GatewayProc $gwHttpsProc `
-TestArgs @("--allow-insecure", "-a", "user", "-s", "pass", "-e", "https://127.0.0.1:7071", "posix", "--windows-test-mode")
Invoke-GwTest -Description "https iam tests" -GatewayProc $gwHttpsProc `
- -TestArgs @("--allow-insecure", "-a", "user", "-s", "pass", "-e", "https://127.0.0.1:7071", "iam")
+ -TestArgs @("--allow-insecure", "-a", "user", "-s", "pass", "-e", "https://127.0.0.1:7071", "gw-iam")
Stop-Process -Id $gwHttpsProc.Id -Force -ErrorAction SilentlyContinue
diff --git a/runtests.sh b/runtests.sh
index a86f3659..2098714b 100755
--- a/runtests.sh
+++ b/runtests.sh
@@ -68,9 +68,9 @@ if ! ./versitygw test -a user -s pass -e http://127.0.0.1:7070 posix; then
kill $GW_PID
exit 1
fi
-# iam tests
-if ! ./versitygw test -a user -s pass -e http://127.0.0.1:7070 iam; then
- echo "iam tests failed"
+# gateway iam tests
+if ! ./versitygw test -a user -s pass -e http://127.0.0.1:7070 gw-iam; then
+ echo "gateway iam tests failed"
kill $GW_PID
exit 1
fi
@@ -105,9 +105,9 @@ if ! ./versitygw test --allow-insecure -a user -s pass -e https://127.0.0.1:7071
kill $GW_HTTPS_PID
exit 1
fi
-# iam tests
-if ! ./versitygw test --allow-insecure -a user -s pass -e https://127.0.0.1:7071 iam; then
- echo "iam tests failed"
+# gateway iam tests
+if ! ./versitygw test --allow-insecure -a user -s pass -e https://127.0.0.1:7071 gw-iam; then
+ echo "gateway iam tests failed"
kill $GW_HTTPS_PID
exit 1
fi
diff --git a/s3api/utils/auth-reader.go b/s3api/utils/auth-reader.go
index c2c5c9dc..12884d2b 100644
--- a/s3api/utils/auth-reader.go
+++ b/s3api/utils/auth-reader.go
@@ -18,97 +18,42 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
- "fmt"
- "os"
- "strings"
+ "errors"
"time"
- "unicode"
- "github.com/aws/aws-sdk-go-v2/aws"
- "github.com/aws/smithy-go/logging"
"github.com/gofiber/fiber/v3"
- v4 "github.com/versity/versitygw/aws/signer/v4"
- "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3err"
)
const (
- iso8601Format = "20060102T150405Z"
- yyyymmdd = "20060102"
+ iso8601Format = sigv4auth.ISO8601Format
+ yyyymmdd = sigv4auth.YYYYMMDD
)
func HexBytes(s string) string {
- b := []byte(s) // raw UTF-8 bytes
-
- parts := make([]string, len(b))
- for i, v := range b {
- parts[i] = fmt.Sprintf("%02x", v)
- }
-
- return strings.Join(parts, " ")
+ return sigv4auth.HexBytes(s)
}
const (
- service = "s3"
+ service = sigv4auth.ServiceS3
)
// CheckValidSignature validates the ctx v4 auth signature
func CheckValidSignature(ctx fiber.Ctx, auth AuthData, secret, checksum string, tdate time.Time, contentLen int64) (string, error) {
- signedHdrs := strings.Split(auth.SignedHeaders, ";")
-
- // Create a new http request instance from fasthttp request
- req, err := createHttpRequestFromCtx(ctx, signedHdrs, contentLen)
+ result, err := sigv4auth.CheckSignature(ctx, auth, secret, checksum, tdate, contentLen, sigv4auth.CheckOptions{
+ Service: service,
+ DisableURIPathEscaping: true,
+ })
if err != nil {
- return "", err
+ return "", mapSigV4Error(err)
}
- signer := v4.NewSigner()
-
- signMeta, err := signer.SignHTTP(req.Context(),
- aws.Credentials{
- AccessKeyID: auth.Access,
- SecretAccessKey: secret,
- },
- req, checksum, service, auth.Region, tdate, signedHdrs,
- func(options *v4.SignerOptions) {
- options.DisableURIPathEscaping = true
- if debuglogger.IsDebugEnabled() {
- options.LogSigning = true
- options.Logger = logging.NewStandardLogger(os.Stderr)
- }
- })
- if err != nil {
- return "", fmt.Errorf("sign generated http request: %w", err)
- }
-
- genAuth, err := ParseAuthorization(req.Header.Get("Authorization"))
- if err != nil {
- return "", err
- }
-
- if auth.Signature != genAuth.Signature {
- return "", s3err.GetSignatureDoesNotMatchErr(
- auth.Access,
- signMeta.StringToSign,
- auth.Signature,
- HexBytes(signMeta.StringToSign),
- signMeta.CanonicalString,
- HexBytes(signMeta.CanonicalString),
- )
- }
-
- return signMeta.CanonicalString, nil
+ return result.CanonicalString, nil
}
-// AuthData is the parsed authorization data from the header
-type AuthData struct {
- Algorithm string
- Access string
- Region string
- SignedHeaders string
- Signature string
- Date string
-}
+// AuthData is the parsed authorization data from the header.
+type AuthData = sigv4auth.AuthData
// ParseAuthorization returns the parsed fields for the aws v4 auth header
// example authorization string from aws docs:
@@ -117,93 +62,14 @@ type AuthData struct {
// SignedHeaders=host;range;x-amz-date,
// Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
func ParseAuthorization(authorization string) (AuthData, error) {
- a := AuthData{}
-
- // authorization must start with:
- // Authorization:
- // followed by key=value pairs separated by ","
- authParts := strings.SplitN(authorization, " ", 2)
- for i, el := range authParts {
- if strings.Contains(el, " ") {
- authParts[i] = removeSpace(el)
- }
+ authData, err := sigv4auth.ParseAuthorization(authorization, service)
+ if err != nil {
+ return AuthData{}, mapSigV4Error(err)
}
-
- if len(authParts) < 2 {
- return a, s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthHeader, authorization)
- }
-
- algo := authParts[0]
- if algo == "AWS" {
- // SigV2 authorization is not supported by the gateway
- return a, s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism)
- }
- if algo != "AWS4-HMAC-SHA256" {
- return a, s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthorizationType, algo)
- }
-
- kvData := authParts[1]
- kvPairs := strings.Split(kvData, ",")
- // we are expecting at least Credential, SignedHeaders, and Signature
- // key value pairs here
- if len(kvPairs) != 3 {
- return a, s3err.MalformedAuth.MissingComponents()
- }
-
- var access, region, signedHeaders, signature, date string
-
- for i, kv := range kvPairs {
- keyValue := strings.Split(kv, "=")
- if len(keyValue) != 2 {
- return a, s3err.MalformedAuth.MalformedComponent(kv)
- }
- key, value := keyValue[0], keyValue[1]
- switch i {
- case 0:
- if key != "Credential" {
- return a, s3err.MalformedAuth.MissingCredential()
- }
- case 1:
- if key != "SignedHeaders" {
- return a, s3err.MalformedAuth.MissingSignedHeaders()
- }
- case 2:
- if key != "Signature" {
- return a, s3err.MalformedAuth.MissingSignature()
- }
- }
-
- switch key {
- case "Credential":
- creds, err := ParseCredentials(value, s3err.MalformedAuth)
- if err != nil {
- return a, err
- }
- access = creds.Access
- date = creds.Date
- region = creds.Region
- case "SignedHeaders":
- signedHeaders = value
- case "Signature":
- signature = value
- }
- }
-
- return AuthData{
- Algorithm: algo,
- Access: access,
- Region: region,
- SignedHeaders: signedHeaders,
- Signature: signature,
- Date: date,
- }, nil
+ return authData, nil
}
-type CredentialsScope struct {
- Access string
- Date string
- Region string
-}
+type CredentialsScope = sigv4auth.CredentialsScope
type CredsError interface {
MalformedCredential(string) s3err.S3Error
@@ -213,36 +79,11 @@ type CredsError interface {
}
func ParseCredentials(input string, errHandler CredsError) (*CredentialsScope, error) {
- creds := strings.Split(input, "/")
- if len(creds) != 5 {
- return nil, errHandler.MalformedCredential(input)
- }
- if creds[3] != "s3" {
- return nil, errHandler.IncorrectService(input, creds[3])
- }
- if creds[4] != "aws4_request" {
- return nil, errHandler.IncorrectTerminal(input, creds[4])
- }
- _, err := time.Parse(yyyymmdd, creds[1])
+ creds, err := sigv4auth.ParseCredentials(input, service)
if err != nil {
- return nil, errHandler.InvalidDateFormat(input, creds[1])
+ return nil, mapCredentialsError(input, err, errHandler)
}
- return &CredentialsScope{
- Access: creds[0],
- Date: creds[1],
- Region: creds[2],
- }, nil
-}
-
-func removeSpace(str string) string {
- var b strings.Builder
- b.Grow(len(str))
- for _, ch := range str {
- if !unicode.IsSpace(ch) {
- b.WriteRune(ch)
- }
- }
- return b.String()
+ return creds, nil
}
func SignPostPolicy(base64Policy, yyyymmdd, region, secretKey string) (string, error) {
@@ -264,3 +105,78 @@ func hmacSHA256(key, data []byte) []byte {
h.Write(data)
return h.Sum(nil)
}
+
+func mapSigV4Error(err error) error {
+ var parseErr *sigv4auth.ParseError
+ if errors.As(err, &parseErr) {
+ return mapAuthParseError(parseErr)
+ }
+
+ var headersErr *sigv4auth.HeadersNotSignedError
+ if errors.As(err, &headersErr) {
+ return s3err.GetHeadersNotSignedErr(headersErr.Headers)
+ }
+
+ var sigErr *sigv4auth.SignatureMismatchError
+ if errors.As(err, &sigErr) {
+ return s3err.GetSignatureDoesNotMatchErr(
+ sigErr.AccessKeyID,
+ sigErr.StringToSign,
+ sigErr.SignatureProvided,
+ sigErr.StringToSignBytes,
+ sigErr.CanonicalRequest,
+ sigErr.CanonicalRequestBytes,
+ )
+ }
+
+ return err
+}
+
+func mapAuthParseError(err *sigv4auth.ParseError) error {
+ switch err.Kind {
+ case sigv4auth.ErrInvalidAuthorizationHeader:
+ return s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthHeader, err.Input)
+ case sigv4auth.ErrUnsupportedAuthorizationVersion:
+ return s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism)
+ case sigv4auth.ErrInvalidAuthorizationType:
+ return s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthorizationType, err.Value)
+ case sigv4auth.ErrMissingComponents:
+ return s3err.MalformedAuth.MissingComponents()
+ case sigv4auth.ErrMissingCredential:
+ return s3err.MalformedAuth.MissingCredential()
+ case sigv4auth.ErrMissingSignedHeaders:
+ return s3err.MalformedAuth.MissingSignedHeaders()
+ case sigv4auth.ErrMissingSignature:
+ return s3err.MalformedAuth.MissingSignature()
+ case sigv4auth.ErrMalformedComponent:
+ return s3err.MalformedAuth.MalformedComponent(err.Value)
+ default:
+ return mapCredentialsParseError(err, s3err.MalformedAuth)
+ }
+}
+
+func mapCredentialsError(input string, err error, errHandler CredsError) error {
+ var parseErr *sigv4auth.ParseError
+ if !errors.As(err, &parseErr) {
+ return err
+ }
+ if parseErr.Input == "" {
+ parseErr.Input = input
+ }
+ return mapCredentialsParseError(parseErr, errHandler)
+}
+
+func mapCredentialsParseError(err *sigv4auth.ParseError, errHandler CredsError) error {
+ switch err.Kind {
+ case sigv4auth.ErrMalformedCredential:
+ return errHandler.MalformedCredential(err.Input)
+ case sigv4auth.ErrIncorrectService:
+ return errHandler.IncorrectService(err.Input, err.Actual)
+ case sigv4auth.ErrIncorrectTerminal:
+ return errHandler.IncorrectTerminal(err.Input, err.Actual)
+ case sigv4auth.ErrInvalidDateFormat:
+ return errHandler.InvalidDateFormat(err.Input, err.Value)
+ default:
+ return err
+ }
+}
diff --git a/s3api/utils/context-keys.go b/s3api/utils/context-keys.go
index 96e84273..374287c6 100644
--- a/s3api/utils/context-keys.go
+++ b/s3api/utils/context-keys.go
@@ -14,48 +14,29 @@
package utils
-import (
- "github.com/gofiber/fiber/v3"
-)
+import "github.com/versity/versitygw/internal/httpctx"
// Region, StartTime, IsRoot, Account, AccessKey context locals
// are set to default values in middlewares.SetDefaultValues
// to avoid the nil interface conversions
-type ContextKey string
+type ContextKey = httpctx.ContextKey
const (
- ContextKeyRegion ContextKey = "region"
- ContextKeyStartTime ContextKey = "start-time"
- ContextKeyIsRoot ContextKey = "is-root"
- ContextKeyRootAccessKey ContextKey = "root-access-key"
- ContextKeyAccount ContextKey = "account"
- ContextKeyAuthenticated ContextKey = "authenticated"
- ContextKeyPublicBucket ContextKey = "public-bucket"
- ContextKeyParsedAcl ContextKey = "parsed-acl"
- ContextKeySkipResBodyLog ContextKey = "skip-res-body-log"
- ContextKeyBodyReader ContextKey = "body-reader"
- ContextKeySkip ContextKey = "__skip"
- ContextKeyStack ContextKey = "stack"
- ContextKeyBucketOwner ContextKey = "bucket-owner"
- ContextKeyObjectPostResult ContextKey = "object-post-result"
- ContextKeyRequestID ContextKey = "request-id"
- ContextKeyHostID ContextKey = "host-id"
- ContextKeyWebsiteConfig ContextKey = "website-config"
+ ContextKeyRegion = httpctx.ContextKeyRegion
+ ContextKeyStartTime = httpctx.ContextKeyStartTime
+ ContextKeyIsRoot = httpctx.ContextKeyIsRoot
+ ContextKeyRootAccessKey = httpctx.ContextKeyRootAccessKey
+ ContextKeyAccount = httpctx.ContextKeyAccount
+ ContextKeyAuthenticated = httpctx.ContextKeyAuthenticated
+ ContextKeyPublicBucket = httpctx.ContextKeyPublicBucket
+ ContextKeyParsedAcl = httpctx.ContextKeyParsedAcl
+ ContextKeySkipResBodyLog = httpctx.ContextKeySkipResBodyLog
+ ContextKeyBodyReader = httpctx.ContextKeyBodyReader
+ ContextKeySkip = httpctx.ContextKeySkip
+ ContextKeyStack = httpctx.ContextKeyStack
+ ContextKeyBucketOwner = httpctx.ContextKeyBucketOwner
+ ContextKeyObjectPostResult = httpctx.ContextKeyObjectPostResult
+ ContextKeyRequestID = httpctx.ContextKeyRequestID
+ ContextKeyHostID = httpctx.ContextKeyHostID
+ ContextKeyWebsiteConfig = httpctx.ContextKeyWebsiteConfig
)
-
-func (ck ContextKey) Set(ctx fiber.Ctx, val any) {
- ctx.Locals(string(ck), val)
-}
-
-func (ck ContextKey) IsSet(ctx fiber.Ctx) bool {
- val := ctx.Locals(string(ck))
- return val != nil
-}
-
-func (ck ContextKey) Delete(ctx fiber.Ctx) {
- ctx.Locals(string(ck), nil)
-}
-
-func (ck ContextKey) Get(ctx fiber.Ctx) any {
- return ctx.Locals(string(ck))
-}
diff --git a/s3api/utils/presign-auth-reader.go b/s3api/utils/presign-auth-reader.go
index 57db3e3f..c22463f3 100644
--- a/s3api/utils/presign-auth-reader.go
+++ b/s3api/utils/presign-auth-reader.go
@@ -15,32 +15,21 @@
package utils
import (
- "fmt"
- "net/url"
- "os"
+ "errors"
"strconv"
- "strings"
"time"
- "github.com/aws/aws-sdk-go-v2/aws"
- "github.com/aws/smithy-go/logging"
"github.com/gofiber/fiber/v3"
- v4 "github.com/versity/versitygw/aws/signer/v4"
- "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3err"
)
const (
unsignedPayload string = "UNSIGNED-PAYLOAD"
-
- algoHMAC string = "AWS4-HMAC-SHA256"
- algoECDSA string = "AWS4-ECDSA-P256-SHA256"
)
// CheckPresignedSignature validates presigned request signature
func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error {
- signedHdrs := strings.Split(auth.SignedHeaders, ";")
-
var contentLength int64
var err error
contentLengthStr := ctx.Get("Content-Length")
@@ -51,44 +40,14 @@ func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error
}
}
- // Create a new http request instance from fasthttp request
- req, err := createPresignedHttpRequestFromCtx(ctx, signedHdrs, contentLength)
- if err != nil {
- return err
- }
-
date, _ := time.Parse(iso8601Format, auth.Date)
- signer := v4.NewSigner()
- uri, _, signMeta, signErr := signer.PresignHTTP(ctx.RequestCtx(), aws.Credentials{
- AccessKeyID: auth.Access,
- SecretAccessKey: secret,
- }, req, unsignedPayload, service, auth.Region, date, signedHdrs, func(options *v4.SignerOptions) {
- options.DisableURIPathEscaping = true
- if debuglogger.IsDebugEnabled() {
- options.LogSigning = true
- options.Logger = logging.NewStandardLogger(os.Stderr)
- }
+ _, err = sigv4auth.CheckQuerySignature(ctx, auth, secret, unsignedPayload, date, contentLength, sigv4auth.CheckOptions{
+ Service: service,
+ DisableURIPathEscaping: true,
})
- if signErr != nil {
- return fmt.Errorf("presign generated http request: %w", err)
- }
-
- urlParts, err := url.Parse(uri)
if err != nil {
- return fmt.Errorf("parse presigned url: %w", err)
- }
-
- signature := urlParts.Query().Get("X-Amz-Signature")
- if signature != auth.Signature {
- return s3err.GetSignatureDoesNotMatchErr(
- auth.Access,
- signMeta.StringToSign,
- auth.Signature,
- HexBytes(signMeta.StringToSign),
- signMeta.CanonicalString,
- HexBytes(signMeta.CanonicalString),
- )
+ return mapSigV4Error(err)
}
return nil
@@ -105,157 +64,83 @@ func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error
// &X-Amz-SignedHeaders=host
// &X-Amz-Signature=1e68ad45c1db540284a4a1eca3884c293ba1a0ff63ab9db9a15b5b29dfa02cd8
func ParsePresignedURIParts(ctx fiber.Ctx, region string) (AuthData, error) {
- a := AuthData{}
-
- // Get and verify algorithm query parameter
- algo := ctx.Query("X-Amz-Algorithm")
- err := validateAlgorithm(algo)
+ auth, _, err := sigv4auth.ParseQueryAuthorization(ctx, sigv4auth.QueryAuthOptions{
+ Service: service,
+ Region: region,
+ RequireExpiration: true,
+ })
if err != nil {
- return a, err
+ return AuthData{}, mapQueryAuthError(err)
}
- // Parse and validate credentials query parameter
- credsQuery := ctx.Query("X-Amz-Credential")
- if credsQuery == "" {
- return a, s3err.QueryAuthErrors.MissingRequiredParams()
- }
-
- creds, err := ParseCredentials(credsQuery, s3err.QueryAuthErrors)
- if err != nil {
- return a, err
- }
-
- // validate the region
- if creds.Region != region {
- return a, s3err.QueryAuthErrors.IncorrectRegion(region, creds.Region)
- }
-
- // Parse and validate Date query param
- date := ctx.Query("X-Amz-Date")
- if date == "" {
- return a, s3err.QueryAuthErrors.MissingRequiredParams()
- }
-
- tdate, err := time.Parse(iso8601Format, date)
- if err != nil {
- return a, s3err.QueryAuthErrors.InvalidXAmzDateFormat()
- }
-
- if date[:8] != creds.Date {
- return a, s3err.QueryAuthErrors.DateMismatch(creds.Date, date[:8])
- }
-
- signature := ctx.Query("X-Amz-Signature")
- if signature == "" {
- return a, s3err.QueryAuthErrors.MissingRequiredParams()
- }
-
- signedHdrs := ctx.Query("X-Amz-SignedHeaders")
- if signedHdrs == "" {
- return a, s3err.QueryAuthErrors.MissingRequiredParams()
- }
-
- // Validate X-Amz-Expires query param and check if request is expired
- err = validateExpiration(ctx.Query("X-Amz-Expires"), tdate)
- if err != nil {
- return a, err
- }
-
- a.Signature = signature
- a.Access = creds.Access
- a.Algorithm = algo
- a.Region = creds.Region
- a.SignedHeaders = signedHdrs
- a.Date = date
-
- return a, nil
+ return auth, nil
}
func validateExpiration(str string, date time.Time) error {
- if str == "" {
- return s3err.QueryAuthErrors.MissingRequiredParams()
- }
-
- exp, err := strconv.Atoi(str)
- if err != nil {
- return s3err.QueryAuthErrors.ExpiresNumber()
- }
-
- if exp < 0 {
- return s3err.QueryAuthErrors.ExpiresNegative()
- }
-
- if exp > 604800 {
- return s3err.QueryAuthErrors.ExpiresTooLarge()
- }
-
- now := time.Now().UTC()
- expiresAt := date.Add(time.Duration(exp) * time.Second)
-
- if expiresAt.Before(now) {
- return s3err.GetExpiredPresignedURLError(exp, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339))
- }
-
- return nil
+ _, err := sigv4auth.ValidateQueryExpiration(str, date, time.Now().UTC())
+ return mapQueryAuthError(err)
}
// validateAlgorithm validates the algorithm
// for AWS4-ECDSA-P256-SHA256 it returns a custom non AWS error
// currently only AWS4-HMAC-SHA256 algorithm is supported
func validateAlgorithm(algo string) error {
- switch algo {
- case "":
- return s3err.QueryAuthErrors.MissingRequiredParams()
- case algoHMAC:
- return nil
- case algoECDSA:
- return s3err.QueryAuthErrors.OnlyHMACSupported()
- default:
- // all other algorithms are considered as invalid
- return s3err.QueryAuthErrors.UnsupportedAlgorithm()
- }
+ return mapQueryAuthError(sigv4auth.ValidateQueryAlgorithm(algo))
}
// IsPresignedURLAuth determines if the request is presigned:
// which is authorization with query params
func IsPresignedURLAuth(ctx fiber.Ctx) bool {
- algo := ctx.Query("X-Amz-Algorithm")
- creds := ctx.Query("X-Amz-Credential")
- signature := ctx.Query("X-Amz-Signature")
- signedHeaders := ctx.Query("X-Amz-SignedHeaders")
- expires := ctx.Query("X-Amz-Expires")
-
- return !allEmpty(algo, creds, signature, signedHeaders, expires) || IsPresignedURLAuthV2(ctx)
+ return sigv4auth.IsQueryAuth(ctx) || ctx.Query(sigv4auth.QueryExpires) != "" || IsPresignedURLAuthV2(ctx)
}
// IsPresignedURLAuthV2 determines if the request is
// query-string signed with aws v2 signer
func IsPresignedURLAuthV2(ctx fiber.Ctx) bool {
- expires := ctx.Query("Expires")
- access := ctx.Query("AWSAccessKeyId")
- signature := ctx.Query("Signature")
-
- return anyNonEmpty(expires, access, signature)
+ return sigv4auth.IsQueryAuthV2(ctx)
}
-// allEmpty reports whether every given string is empty.
-func allEmpty(args ...string) bool {
- for _, a := range args {
- if a != "" {
- return false
+func mapQueryAuthError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ var queryErr *sigv4auth.QueryError
+ if errors.As(err, &queryErr) {
+ switch queryErr.Kind {
+ case sigv4auth.ErrQueryMissingRequiredParams:
+ return s3err.QueryAuthErrors.MissingRequiredParams()
+ case sigv4auth.ErrQueryUnsupportedAlgorithm:
+ return s3err.QueryAuthErrors.UnsupportedAlgorithm()
+ case sigv4auth.ErrQueryUnsupportedECDSA:
+ return s3err.QueryAuthErrors.OnlyHMACSupported()
+ case sigv4auth.ErrQueryInvalidDateFormat:
+ return s3err.QueryAuthErrors.InvalidXAmzDateFormat()
+ case sigv4auth.ErrQueryDateMismatch:
+ return s3err.QueryAuthErrors.DateMismatch(queryErr.Expected, queryErr.Actual)
+ case sigv4auth.ErrQueryIncorrectRegion:
+ return s3err.QueryAuthErrors.IncorrectRegion(queryErr.Expected, queryErr.Actual)
+ case sigv4auth.ErrQueryExpiresNumber:
+ return s3err.QueryAuthErrors.ExpiresNumber()
+ case sigv4auth.ErrQueryExpiresNegative:
+ return s3err.QueryAuthErrors.ExpiresNegative()
+ case sigv4auth.ErrQueryExpiresTooLarge:
+ return s3err.QueryAuthErrors.ExpiresTooLarge()
+ case sigv4auth.ErrQueryExpired:
+ return s3err.GetExpiredPresignedURLError(
+ queryErr.Expires,
+ queryErr.ExpiresAt.Format(time.RFC3339),
+ queryErr.ServerTime.Format(time.RFC3339),
+ )
+ case sigv4auth.ErrQuerySecurityToken:
+ return s3err.QueryAuthErrors.SecurityTokenNotSupported()
}
}
- return true
-}
-
-// anyNonEmpty reports whether at least one given string is non-empty.
-func anyNonEmpty(args ...string) bool {
- for _, a := range args {
- if a != "" {
- return true
- }
+ var parseErr *sigv4auth.ParseError
+ if errors.As(err, &parseErr) {
+ return mapCredentialsParseError(parseErr, s3err.QueryAuthErrors)
}
- return false
+ return err
}
diff --git a/s3api/utils/request_ids.go b/s3api/utils/request_ids.go
index 4a8d366a..0a18b5bf 100644
--- a/s3api/utils/request_ids.go
+++ b/s3api/utils/request_ids.go
@@ -27,9 +27,8 @@ const (
HeaderAmzID2 = "x-amz-id-2"
s3RequestIDAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
- s3RequestIDLength = 16
- s3HostIDBytes = 65
+ s3RequestIDLength = 16
+ s3HostIDBytes = 65
)
// NewS3RequestID returns a request ID, for example
diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go
index e3375575..d1271954 100644
--- a/s3api/utils/utils.go
+++ b/s3api/utils/utils.go
@@ -170,57 +170,6 @@ func createHttpRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength
return httpReq, nil
}
-var (
- signedQueryArgs = map[string]bool{
- "X-Amz-Algorithm": true,
- "X-Amz-Credential": true,
- "X-Amz-Date": true,
- "X-Amz-SignedHeaders": true,
- "X-Amz-Signature": true,
- }
-)
-
-func createPresignedHttpRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64) (*http.Request, error) {
- req := ctx.Request()
-
- uri, _, _ := strings.Cut(ctx.OriginalURL(), "?")
- isFirst := true
-
- for key, value := range ctx.Request().URI().QueryArgs().All() {
- _, ok := signedQueryArgs[string(key)]
- if !ok {
- escapeValue := url.QueryEscape(string(value))
- if isFirst {
- uri += fmt.Sprintf("?%s=%s", key, escapeValue)
- isFirst = false
- } else {
- uri += fmt.Sprintf("&%s=%s", key, escapeValue)
- }
- }
- }
-
- httpReq, err := http.NewRequest(string(req.Header.Method()), uri, nil)
- if err != nil {
- return nil, errors.New("error in creating an http request")
- }
- if err := addRequestHeadersFromCtx(ctx, httpReq, signedHdrs); err != nil {
- return nil, err
- }
-
- // Check if Content-Length in signed headers
- // If content length is non 0, then the header will be included
- if !includeHeader("Content-Length", signedHdrs) {
- httpReq.ContentLength = 0
- } else {
- httpReq.ContentLength = contentLength
- }
-
- // Set the Host header
- httpReq.Host = string(req.Header.Host())
-
- return httpReq, nil
-}
-
func SetMetaHeaders(ctx fiber.Ctx, meta map[string]string) {
ctx.Response().Header.DisableNormalizing()
for key, val := range meta {
diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go
index 89503a05..3fde9d1a 100644
--- a/tests/integration/group-tests.go
+++ b/tests/integration/group-tests.go
@@ -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,
diff --git a/tests/integration/iam_auth.go b/tests/integration/iam_auth.go
new file mode 100644
index 00000000..3306fe4b
--- /dev/null
+++ b/tests/integration/iam_auth.go
@@ -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(),
+ }
+}
diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go
new file mode 100644
index 00000000..12a0a62e
--- /dev/null
+++ b/tests/integration/iam_create_user.go
@@ -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
+}
diff --git a/tests/integration/iam_delete_user.go b/tests/integration/iam_delete_user.go
new file mode 100644
index 00000000..1271b1dd
--- /dev/null
+++ b/tests/integration/iam_delete_user.go
@@ -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))
+ })
+}
diff --git a/tests/integration/iam_get_user.go b/tests/integration/iam_get_user.go
new file mode 100644
index 00000000..4a6b7e66
--- /dev/null
+++ b/tests/integration/iam_get_user.go
@@ -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
+ })
+}
diff --git a/tests/integration/iam_list_users.go b/tests/integration/iam_list_users.go
new file mode 100644
index 00000000..7da6b266
--- /dev/null
+++ b/tests/integration/iam_list_users.go
@@ -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
+}
diff --git a/tests/integration/iam_query_auth.go b/tests/integration/iam_query_auth.go
new file mode 100644
index 00000000..0ab03991
--- /dev/null
+++ b/tests/integration/iam_query_auth.go
@@ -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
+}
diff --git a/tests/integration/iam_update_user.go b/tests/integration/iam_update_user.go
new file mode 100644
index 00000000..abea9ec9
--- /dev/null
+++ b/tests/integration/iam_update_user.go
@@ -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)
+}
diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go
index df243d95..71506012 100644
--- a/tests/integration/s3conf.go
+++ b/tests/integration/s3conf.go
@@ -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())
}
diff --git a/tests/integration/utils.go b/tests/integration/utils.go
index 0b171ff7..2a2e4856 100644
--- a/tests/integration/utils.go
+++ b/tests/integration/utils.go
@@ -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