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