From e012a6fd01f4b7cab23e1e412bb2abc7e997db09 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Thu, 2 Jul 2026 22:54:47 +0400 Subject: [PATCH 01/10] feat: add AWS-compatible standalone IAM service Closes #1640 Add a standalone AWS IAM Query API implementation for managing IAM users through standard AWS SDKs and the AWS CLI. Server usage Start the IAM server with internal file-backed storage: mkdir -p /tmp/versitygw-iam ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --dir /tmp/versitygw-iam Start the IAM server with Vault KV v2 storage using AppRole: VGW_IAM_VAULT_ROLE_SECRET= ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --vault-endpoint-url http://127.0.0.1:8200 --vault-auth-method approle --vault-role-id --vault-mount-path kv --vault-secret-storage-path iam Vault authentication also supports root tokens, separate authentication and secret-storage namespaces, custom mount paths, server certificate validation, and mutual TLS client certificates. Configure the AWS CLI credentials used by the IAM server: export AWS_ACCESS_KEY_ID=user export AWS_SECRET_ACCESS_KEY=pass export AWS_DEFAULT_REGION=us-east-1 Implemented IAM actions CreateUser creates an IAM user with an AWS-compatible ARN, generated AIDA user ID, creation timestamp, optional path, and tags. It validates usernames, paths, tag limits, reserved tag prefixes, duplicate tag keys, and existing users. aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob --path /engineering/ --tags Key=team,Value=storage GetUser returns a stored user or the root identity when requested without a username through the IAM Query API. aws --endpoint-url http://127.0.0.1:7070 iam get-user --user-name bob ListUsers returns users in deterministic username order and supports path filtering, marker-based pagination, and MaxItems limits. aws --endpoint-url http://127.0.0.1:7070 iam list-users aws --endpoint-url http://127.0.0.1:7070 iam list-users --path-prefix /engineering/ --max-items 100 UpdateUser updates the username and/or path, recalculates the user ARN, and rejects conflicts with existing users. aws --endpoint-url http://127.0.0.1:7070 iam update-user --user-name bob --new-user-name robert --new-path /platform/ DeleteUser permanently removes an IAM user and returns AWS-compatible errors for missing users. aws --endpoint-url http://127.0.0.1:7070 iam delete-user --user-name robert IAM protocol and authentication - Support the AWS IAM Query protocol version 2010-05-08 over GET and POST form requests. - Return AWS-compatible XML responses, error documents, status codes, request IDs, user metadata, and pagination fields. - Authenticate root credentials with AWS Signature Version 4 for the IAM service in us-east-1. - Support both Authorization-header and query-string SigV4 authentication. - Validate credential scope, signed headers, timestamps, clock skew, content length, signatures, and unsupported signature or session-token modes. - Add IAM-specific validation and error mapping for malformed requests, invalid actions, duplicate entities, missing users, throttling, and internal failures. Storage implementations - Add an internal JSON-backed store using iam.json and iam.json.backup with atomic temporary-file replacement, concurrent access protection, stable ordering, pagination, and persistence across restarts. - Add a Vault KV v2 store with one secret per user, CAS-based duplicate protection, permanent deletion, AppRole reauthentication, namespace support, configurable authentication and KV mounts, root-token authentication, and TLS/mTLS configuration. - Introduce a common Storer interface and require exactly one storage backend to be configured. Server and embedding support - Register the new `versitygw iam` command with environment-variable and CLI configuration for both storage backends. - Add `embedgw.RunIAMAPI` and `IAMConfig` for embedding the IAM service in Go applications. Gateway-level internal packages - Add `internal/iamstore` as a reusable generic file-backed IAM persistence engine and migrate the existing gateway internal IAM service to it. - Add `internal/sigv4auth` for shared SigV4 header and presigned-query parsing, canonical request generation, signature verification, and structured authentication errors. - Refactor the S3 authentication paths to use the shared SigV4 implementation while preserving S3-specific error responses. - Add `internal/httpctx` for shared Fiber context keys and AWS-style request ID handling. - Add `internal/routekit` for shared query, form, and header route matchers. - Add `internal/netutil` for reusable certificate storage, hostname-aware listeners, multi-address serving, TLS listeners, and UNIX socket handling. - Update the custom SigV4 signer to honor an explicitly supplied signed-header list so unrelated headers do not alter IAM signatures. Testing and CI - Add AWS IAM SDK-based integration coverage for all supported user actions, header authentication, query authentication, validation, errors, filtering, and pagination. - Split standalone IAM tests into `versitygw test iam` and retain existing gateway IAM tests under `versitygw test gw-iam`. - Add unit coverage for controllers, authentication, routing, storage, embedding, listeners, request matching, persistence, and signing behavior. - Add `runiamtests.sh` to exercise internal storage over HTTP and HTTPS plus Vault storage through AppRole. - Add a dedicated IAM functional-test workflow with a Vault service and merged runtime coverage reporting. - Include the IAM test runner in shellcheck and add the AWS IAM SDK dependency. --- .github/workflows/functional-iam.yml | 50 ++ .github/workflows/shellcheck.yml | 2 +- auth/iam_internal.go | 177 +----- aws/signer/v4/v4.go | 12 +- aws/signer/v4/v4_test.go | 24 + cmd/internal/gwcli/iam.go | 114 ++++ cmd/versitygw/iam.go | 67 ++ cmd/versitygw/main.go | 2 + cmd/versitygw/test.go | 7 +- embedgw/iam.go | 384 ++++++++++++ embedgw/iam_test.go | 138 ++++ go.mod | 7 +- go.sum | 14 +- iamapi/authentication_test.go | 626 +++++++++++++++++++ iamapi/controller.go | 235 +++++++ iamapi/controller_test.go | 524 ++++++++++++++++ iamapi/iamerr/errors.go | 393 ++++++++++++ iamapi/internal/iammiddleware/auth.go | 258 ++++++++ iamapi/internal/iammiddleware/debug.go | 37 ++ iamapi/internal/iammiddleware/errors.go | 44 ++ iamapi/internal/iammiddleware/ratelimiter.go | 38 ++ iamapi/internal/iammiddleware/requestid.go | 45 ++ iamapi/internal/iamutil/request.go | 40 ++ iamapi/internal/iamutil/request_test.go | 74 +++ iamapi/internal/iamutil/user.go | 213 +++++++ iamapi/response.go | 136 ++++ iamapi/router.go | 91 +++ iamapi/router_test.go | 206 ++++++ iamapi/server.go | 207 ++++++ iamapi/storage/internal.go | 233 +++++++ iamapi/storage/storer.go | 109 ++++ iamapi/storage/storer_test.go | 207 ++++++ iamapi/storage/vault.go | 435 +++++++++++++ iamapi/types/user.go | 113 ++++ internal/httpctx/context_keys.go | 56 ++ internal/iamstore/engine.go | 194 ++++++ internal/iamstore/engine_test.go | 71 +++ internal/netutil/cert.go | 44 ++ internal/netutil/multi_listener.go | 316 ++++++++++ internal/sigv4auth/auth.go | 231 +++++++ internal/sigv4auth/query.go | 406 ++++++++++++ internal/sigv4auth/verify.go | 213 +++++++ runiamtests.sh | 210 +++++++ runtests.ps1 | 4 +- runtests.sh | 12 +- s3api/utils/auth-reader.go | 278 +++----- s3api/utils/context-keys.go | 57 +- s3api/utils/presign-auth-reader.go | 227 ++----- s3api/utils/request_ids.go | 5 +- s3api/utils/utils.go | 51 -- tests/integration/group-tests.go | 191 +++++- tests/integration/iam_auth.go | 365 +++++++++++ tests/integration/iam_create_user.go | 269 ++++++++ tests/integration/iam_delete_user.go | 67 ++ tests/integration/iam_get_user.go | 156 +++++ tests/integration/iam_list_users.go | 367 +++++++++++ tests/integration/iam_query_auth.go | 340 ++++++++++ tests/integration/iam_update_user.go | 201 ++++++ tests/integration/s3conf.go | 5 + tests/integration/utils.go | 139 +++- 60 files changed, 9108 insertions(+), 629 deletions(-) create mode 100644 .github/workflows/functional-iam.yml create mode 100644 cmd/internal/gwcli/iam.go create mode 100644 cmd/versitygw/iam.go create mode 100644 embedgw/iam.go create mode 100644 embedgw/iam_test.go create mode 100644 iamapi/authentication_test.go create mode 100644 iamapi/controller.go create mode 100644 iamapi/controller_test.go create mode 100644 iamapi/iamerr/errors.go create mode 100644 iamapi/internal/iammiddleware/auth.go create mode 100644 iamapi/internal/iammiddleware/debug.go create mode 100644 iamapi/internal/iammiddleware/errors.go create mode 100644 iamapi/internal/iammiddleware/ratelimiter.go create mode 100644 iamapi/internal/iammiddleware/requestid.go create mode 100644 iamapi/internal/iamutil/request.go create mode 100644 iamapi/internal/iamutil/request_test.go create mode 100644 iamapi/internal/iamutil/user.go create mode 100644 iamapi/response.go create mode 100644 iamapi/router.go create mode 100644 iamapi/router_test.go create mode 100644 iamapi/server.go create mode 100644 iamapi/storage/internal.go create mode 100644 iamapi/storage/storer.go create mode 100644 iamapi/storage/storer_test.go create mode 100644 iamapi/storage/vault.go create mode 100644 iamapi/types/user.go create mode 100644 internal/httpctx/context_keys.go create mode 100644 internal/iamstore/engine.go create mode 100644 internal/iamstore/engine_test.go create mode 100644 internal/netutil/cert.go create mode 100644 internal/netutil/multi_listener.go create mode 100644 internal/sigv4auth/auth.go create mode 100644 internal/sigv4auth/query.go create mode 100644 internal/sigv4auth/verify.go create mode 100755 runiamtests.sh create mode 100644 tests/integration/iam_auth.go create mode 100644 tests/integration/iam_create_user.go create mode 100644 tests/integration/iam_delete_user.go create mode 100644 tests/integration/iam_get_user.go create mode 100644 tests/integration/iam_list_users.go create mode 100644 tests/integration/iam_query_auth.go create mode 100644 tests/integration/iam_update_user.go 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/internal/gwcli/iam.go b/cmd/internal/gwcli/iam.go new file mode 100644 index 00000000..705ad80d --- /dev/null +++ b/cmd/internal/gwcli/iam.go @@ -0,0 +1,114 @@ +// 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 gwcli + +import ( + "github.com/urfave/cli/v2" +) + +// RunIAM starts the standalone IAM API server for the given command +// context. The hosting binary's main package must set this before running +// the "iam" command. +var RunIAM func(ctx *cli.Context) error + +// IAMCommand returns the "iam" subcommand, common to all versitygw binaries. +func IAMCommand() *cli.Command { + return &cli.Command{ + Name: "iam", + Usage: "IAM API server", + Description: "Run the standalone IAM API server.", + Action: func(ctx *cli.Context) error { + return RunIAM(ctx) + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dir", + Usage: "directory path for file-backed IAM storage", + EnvVars: []string{"VGW_IAM_DIR"}, + }, + &cli.StringFlag{ + Name: "vault-endpoint-url", + Usage: "vault server url for IAM storage", + EnvVars: []string{"VGW_IAM_VAULT_ENDPOINT_URL"}, + }, + &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"}, + }, + &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"}, + }, + &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"}, + }, + &cli.StringFlag{ + Name: "vault-auth-method", + Usage: "vault auth method mount path (default: approle)", + EnvVars: []string{"VGW_IAM_VAULT_AUTH_METHOD"}, + }, + &cli.StringFlag{ + Name: "vault-auth-namespace", + Usage: "vault namespace for AppRole login (overrides vault-namespace)", + EnvVars: []string{"VGW_IAM_VAULT_AUTH_NAMESPACE"}, + }, + &cli.StringFlag{ + Name: "vault-mount-path", + Usage: "vault KV v2 engine mount path (default: kv-v2)", + EnvVars: []string{"VGW_IAM_VAULT_MOUNT_PATH"}, + }, + &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"}, + }, + &cli.StringFlag{ + Name: "vault-role-id", + Usage: "vault AppRole role ID for authentication", + EnvVars: []string{"VGW_IAM_VAULT_ROLE_ID"}, + }, + &cli.StringFlag{ + Name: "vault-role-secret", + Usage: "vault AppRole secret ID for authentication", + EnvVars: []string{"VGW_IAM_VAULT_ROLE_SECRET"}, + }, + &cli.StringFlag{ + Name: "vault-server-cert", + Usage: "PEM-encoded vault server TLS certificate for verification", + EnvVars: []string{"VGW_IAM_VAULT_SERVER_CERT"}, + }, + &cli.StringFlag{ + Name: "vault-client-cert", + Usage: "PEM-encoded client TLS certificate presented to vault", + EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT"}, + }, + &cli.StringFlag{ + Name: "vault-client-cert-key", + Usage: "PEM-encoded private key for vault-client-cert", + EnvVars: []string{"VGW_IAM_VAULT_CLIENT_CERT_KEY"}, + }, + &cli.BoolFlag{ + Name: "quiet", + Usage: "silence stdout request logging output", + EnvVars: []string{"VGW_QUIET"}, + Aliases: []string{"q"}, + }, + }, + } +} diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go new file mode 100644 index 00000000..c80d098b --- /dev/null +++ b/cmd/versitygw/iam.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 main + +import ( + "log" + "net/http" + + "github.com/urfave/cli/v2" + "github.com/versity/versitygw/cmd/internal/gwcli" + "github.com/versity/versitygw/embedgw" +) + +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: gwcli.RootUserAccess, + RootUserSecret: gwcli.RootUserSecret, + Ports: ports, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + CertFile: certFile, + KeyFile: keyFile, + Debug: debug, + Quiet: quiet || ctx.Bool("quiet"), + KeepAlive: keepAlive, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: ctx.String("dir"), + VaultEndpointURL: ctx.String("vault-endpoint-url"), + VaultNamespace: ctx.String("vault-namespace"), + VaultSecretStoragePath: ctx.String("vault-secret-storage-path"), + VaultSecretStorageNamespace: ctx.String("vault-secret-storage-namespace"), + VaultAuthMethod: ctx.String("vault-auth-method"), + VaultAuthNamespace: ctx.String("vault-auth-namespace"), + VaultMountPath: ctx.String("vault-mount-path"), + VaultRootToken: ctx.String("vault-root-token"), + VaultRoleID: ctx.String("vault-role-id"), + VaultRoleSecret: ctx.String("vault-role-secret"), + VaultServerCert: ctx.String("vault-server-cert"), + VaultClientCert: ctx.String("vault-client-cert"), + VaultClientCertKey: ctx.String("vault-client-cert-key"), + Version: Version, + Build: Build, + BuildTime: BuildTime, + }) +} diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 743b9142..f2e84869 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -110,6 +110,7 @@ var ( func main() { gwcli.SetupSignalHandler() gwcli.RunGateway = runGateway + gwcli.RunIAM = runIAM app := initApp() @@ -119,6 +120,7 @@ func main() { gwcli.S3Command(), gwcli.AzureCommand(), gwcli.PluginCommand(), + gwcli.IAMCommand(), gwcli.AdminCommand(), testCommand(), gwcli.UtilsCommand(), diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index e65b33fc..7c01507d 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 c5fde0ae..7b8cc2c3 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 github.com/DataDog/datadog-go/v5 v5.9.0 - github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2 v1.43.7 github.com/aws/aws-sdk-go-v2/config v1.32.37 github.com/aws/aws-sdk-go-v2/credentials v1.19.36 github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.13 + github.com/aws/aws-sdk-go-v2/service/iam v1.59.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 github.com/aws/smithy-go v1.27.8 github.com/cespare/xxhash/v2 v2.3.0 @@ -45,8 +46,8 @@ require ( github.com/andybalholm/brotli v1.2.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 // indirect diff --git a/go.sum b/go.sum index 03af1be7..905340b6 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= -github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= +github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= @@ -37,12 +37,14 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdS github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.13 h1:pM4L5lw8RaUkDAZYpHenVWLI2DJ4Qz5S4oDphjxoPvk= github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.13/go.mod h1:Wl+WygckBndyBhVf1kOVUCYBtS4KI2pHgcN8jGsYhwE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/iam v1.59.2 h1:An/8OH+HhHxKar6bDa8v1ITiG7V3/lU+TwErOzzCm1M= +github.com/aws/aws-sdk-go-v2/service/iam v1.59.2/go.mod h1:gQRbwtyMFDBnV/58n5bVWmZi50c7H2HN/+ikrW876So= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 h1:5437eMoOwqqQpZn2XJy74mlDCuPYL81texMT3mXqgtU= 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 6dbeabb1..7fa11c79 100755 --- a/runtests.sh +++ b/runtests.sh @@ -70,9 +70,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 @@ -107,9 +107,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 79baff1d..a7ace12e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -947,7 +947,7 @@ func TestFullFlow(ts *TestState) { if ts.conf.versioningEnabled { TestVersioning(ts) } - TestIAM(ts) + TestGatewayIAM(ts) TestServer(ts) } @@ -1070,7 +1070,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) @@ -1082,6 +1082,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) @@ -1396,6 +1505,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 From 9b352a3f00bc9693d1813617286bbf2bb200ef58 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 6 Jul 2026 22:59:24 +0400 Subject: [PATCH 02/10] feat: add IAM user access key management Add `CreateAccessKey`, `UpdateAccessKey`, `DeleteAccessKey`, `ListAccessKeys`, and `GetAccessKeyLastUsed` actions for managing user access keys and retrieving their latest usage details. Generate AWS-style access key IDs and secrets, validate key identifiers and statuses, enforce per-user key quotas, and prevent deleting users that still own access keys. Persist access keys across internal and Vault storage backends with ownership indexing, pagination, and IAM-compatible errors and XML responses. --- iamapi/controller.go | 197 +++++++++++ iamapi/iamerr/errors.go | 41 ++- iamapi/internal/iamutil/access_key.go | 88 +++++ iamapi/internal/iamutil/user.go | 18 +- iamapi/router.go | 7 + iamapi/storage/internal.go | 224 +++++++++++- iamapi/storage/storer.go | 47 ++- iamapi/storage/storer_test.go | 16 + iamapi/storage/vault.go | 188 +++++++++- iamapi/types/access_key.go | 121 +++++++ iamapi/types/user.go | 13 +- tests/integration/group-tests.go | 108 ++++++ tests/integration/iam_create_access_key.go | 151 ++++++++ tests/integration/iam_create_user.go | 17 + tests/integration/iam_delete_access_key.go | 169 +++++++++ tests/integration/iam_delete_user.go | 29 ++ .../iam_get_access_key_last_used.go | 137 ++++++++ tests/integration/iam_list_access_keys.go | 331 ++++++++++++++++++ tests/integration/iam_update_access_key.go | 254 ++++++++++++++ 19 files changed, 2129 insertions(+), 27 deletions(-) create mode 100644 iamapi/internal/iamutil/access_key.go create mode 100644 iamapi/types/access_key.go create mode 100644 tests/integration/iam_create_access_key.go create mode 100644 tests/integration/iam_delete_access_key.go create mode 100644 tests/integration/iam_get_access_key_last_used.go create mode 100644 tests/integration/iam_list_access_keys.go create mode 100644 tests/integration/iam_update_access_key.go diff --git a/iamapi/controller.go b/iamapi/controller.go index 34511a3f..9718836e 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -233,3 +233,200 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { Result: types.UpdateUserResult{User: updated}, }}, nil } + +func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required CreateAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + for range 3 { + accessKeyID, err := iamutil.GenerateAccessKeyID() + if err != nil { + return nil, err + } + secretAccessKey, err := iamutil.GenerateSecretAccessKey() + if err != nil { + return nil, err + } + + stored, err := c.store.CreateAccessKey(ctx.Context(), storage.CreateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + Status: iamutil.AccessKeyStatusActive, + CreateDate: time.Now().UTC().Truncate(time.Second), + }) + if errors.Is(err, storage.ErrAccessKeyIDAlreadyExists) { + debuglogger.Logf("IAM access key id collision for user %q: %v", userName, err) + continue + } + if err != nil { + debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) + return nil, err + } + + return &Response{ + Data: &types.CreateAccessKeyResponse{ + Result: types.CreateAccessKeyResult{AccessKey: *stored}, + }, + }, nil + } + + err := fmt.Errorf("generate IAM access key id: exhausted collision retries") + debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) + return nil, err +} + +func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + status, ok := iamutil.RequestParam(ctx, "Status") + if !ok || status == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: Status") + return nil, iamerr.MissingParameter("Status") + } + if err := iamutil.ValidateAccessKeyStatus(status); err != nil { + return nil, err + } + + if err := c.store.UpdateAccessKey(ctx.Context(), storage.UpdateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + Status: status, + }); err != nil { + debuglogger.Logf("failed to update IAM access key %q for user %q: %v", accessKeyID, userName, err) + return nil, err + } + + return &Response{Data: &types.UpdateAccessKeyResponse{}}, nil +} + +func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required DeleteAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required DeleteAccessKey parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + if err := c.store.DeleteAccessKey(ctx.Context(), userName, accessKeyID); err != nil { + debuglogger.Logf("failed to delete IAM access key %q for user %q: %v", accessKeyID, userName, err) + return nil, err + } + + return &Response{Data: &types.DeleteAccessKeyResponse{}}, nil +} + +func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error) { + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required GetAccessKeyLastUsed parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + out, err := c.store.GetAccessKeyLastUsed(ctx.Context(), accessKeyID) + if err != nil { + debuglogger.Logf("failed to get IAM access key last used %q: %v", accessKeyID, err) + return nil, err + } + + serviceName := out.ServiceName + if serviceName == "" { + serviceName = "N/A" + } + region := out.Region + if region == "" { + region = "N/A" + } + + var lastUsedDate *time.Time + if !out.LastUsedDate.IsZero() { + lastUsedDate = &out.LastUsedDate + } + + return &Response{Data: &types.GetAccessKeyLastUsedResponse{ + Result: types.GetAccessKeyLastUsedResult{ + UserName: out.UserName, + AccessKeyLastUsed: types.AccessKeyLastUsed{ + LastUsedDate: lastUsedDate, + ServiceName: serviceName, + Region: region, + }, + }, + }}, nil +} + +func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required ListAccessKeys parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); 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 ListAccessKeys 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.ListAccessKeys(ctx.Context(), storage.ListAccessKeysInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM access keys for user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListAccessKeysResponse{ + Result: types.ListAccessKeysResult{ + AccessKeyMetadata: types.AccessKeyMetadataList{Members: out.AccessKeys}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 28f52871..f3c2d001 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -52,14 +52,14 @@ const ( ErrInvalidRegion ErrMissingHostSignedHeader ErrInvalidClientTokenID - ErrInvalidContentLength ErrThrottling - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys + ErrInvalidAccessKeyIDChars + ErrDeleteConflict ) type APIError interface { @@ -123,7 +123,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "The request processing has failed because of an unknown error, exception or failure.", HTTPStatusCode: http.StatusInternalServerError, }, - ErrInvalidContentLength: { Type: TypeSender, Code: "InvalidRequest", @@ -136,7 +135,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Rate exceeded.", HTTPStatusCode: http.StatusBadRequest, }, - ErrMissingAuthenticationToken: { Type: TypeSender, Code: "MissingAuthenticationToken", @@ -155,7 +153,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "The security token included in the request is invalid.", HTTPStatusCode: http.StatusForbidden, }, - ErrIncompleteSignature: { Type: TypeSender, Code: "IncompleteSignature", @@ -174,7 +171,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Authorization header requires Credential, SignedHeaders, and Signature.", HTTPStatusCode: http.StatusBadRequest, }, - ErrSignatureDoesNotMatch: { Type: TypeSender, Code: "SignatureDoesNotMatch", @@ -211,7 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.", HTTPStatusCode: http.StatusForbidden, }, - ErrMissingUserNameValue: { Type: TypeSender, Code: "ValidationError", @@ -236,6 +231,18 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidAccessKeyIDChars: { + Type: TypeSender, + Code: "ValidationError", + Message: "The specified value for accessKeyId is invalid. It must contain only alphanumeric characters.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrDeleteConflict: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete access keys first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -334,6 +341,14 @@ func NoSuchEntityUser(userName string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The user with name %s cannot be found.", userName), http.StatusNotFound) } +func NoSuchEntityAccessKey(accessKeyID string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The Access Key with id %s cannot be found", accessKeyID), http.StatusNotFound) +} + +func AccessKeysLimitExceeded(maxKeys int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for AccessKeysPerUser: %d", maxKeys), http.StatusConflict) +} + func ValidationError(message string) Error { return newSenderError("ValidationError", message, http.StatusBadRequest) } @@ -362,6 +377,18 @@ 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 AccessKeyIDTooShort(minLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length greater than or equal to %d", minLength)) +} + +func AccessKeyIDTooLong(maxLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length less than or equal to %d", maxLength)) +} + +func InvalidAccessKeyStatus(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'status' failed to satisfy constraint: Member must satisfy enum value set: [Active, Inactive]", 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)) } diff --git a/iamapi/internal/iamutil/access_key.go b/iamapi/internal/iamutil/access_key.go new file mode 100644 index 00000000..70457c4b --- /dev/null +++ b/iamapi/internal/iamutil/access_key.go @@ -0,0 +1,88 @@ +// 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" + "encoding/base64" + "regexp" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const ( + AccessKeyStatusActive = "Active" + AccessKeyStatusInactive = "Inactive" + + accessKeyIDPrefix = "AKIA" + accessKeyIDRandomLen = 17 + minAccessKeyIDLen = 16 + maxAccessKeyIDLen = 128 + secretAccessKeyBytes = 30 +) + +var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`) + +// GenerateAccessKeyID returns a new cryptographically random IAM access key +// id in the AKIA… format. +func GenerateAccessKeyID() (string, error) { + id, err := generateAWSID(accessKeyIDPrefix, accessKeyIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM access key id: %v", err) + return "", err + } + return id, nil +} + +// GenerateSecretAccessKey returns a new cryptographically random 40 character +// secret access key. +func GenerateSecretAccessKey() (string, error) { + b := make([]byte, secretAccessKeyBytes) + if _, err := rand.Read(b); err != nil { + debuglogger.Logf("failed to generate IAM secret access key: %v", err) + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// ValidateAccessKeyID checks that accessKeyID fits within the allowed length +// range and character set. +func ValidateAccessKeyID(accessKeyID string) error { + if len(accessKeyID) < minAccessKeyIDLen { + debuglogger.Logf("IAM access key id too short: value=%q", accessKeyID) + return iamerr.AccessKeyIDTooShort(minAccessKeyIDLen) + } + if len(accessKeyID) > maxAccessKeyIDLen { + debuglogger.Logf("IAM access key id too long: value=%q", accessKeyID) + return iamerr.AccessKeyIDTooLong(maxAccessKeyIDLen) + } + if !accessKeyIDPattern.MatchString(accessKeyID) { + debuglogger.Logf("invalid IAM access key id characters: value=%q", accessKeyID) + return iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars) + } + + return nil +} + +// ValidateAccessKeyStatus checks that status is either Active or Inactive. +func ValidateAccessKeyStatus(status string) error { + if status != AccessKeyStatusActive && status != AccessKeyStatusInactive { + debuglogger.Logf("invalid IAM access key status: %q", status) + return iamerr.InvalidAccessKeyStatus(status) + } + + return nil +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 1922aa85..19bcb921 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -151,15 +151,25 @@ func BuildUserArn(accountID, path, userName string) string { // GenerateUserID returns a new cryptographically random IAM user ID in the AIDA… format. func GenerateUserID() (string, error) { + id, err := generateAWSID(userIDPrefix, userIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM user ID: %v", err) + return "", err + } + return id, nil +} + +// generateAWSID builds an AWS-style unique identifier: a fixed prefix +// followed by randomLen characters drawn from userIDAlphabet. +func generateAWSID(prefix string, randomLen int) (string, error) { var b strings.Builder - b.Grow(len(userIDPrefix) + userIDRandomLen) - b.WriteString(userIDPrefix) + b.Grow(len(prefix) + randomLen) + b.WriteString(prefix) max := big.NewInt(int64(len(userIDAlphabet))) - for range userIDRandomLen { + for range randomLen { 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()]) diff --git a/iamapi/router.go b/iamapi/router.go index 823cba05..ce6eb55c 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -45,11 +45,18 @@ func (r *IAMApiRouter) Init() { r.Ctrl = ctrl r.actions = map[string]ActionHandler{ + // User CRUD "CreateUser": ctrl.CreateUser, "DeleteUser": ctrl.DeleteUser, "GetUser": ctrl.GetUser, "ListUsers": ctrl.ListUsers, "UpdateUser": ctrl.UpdateUser, + // User Access Key CRUD + "CreateAccessKey": ctrl.CreateAccessKey, + "UpdateAccessKey": ctrl.UpdateAccessKey, + "DeleteAccessKey": ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": ctrl.ListAccessKeys, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index be4e0f09..3c3b1692 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -50,16 +50,25 @@ func NewInternal(dir string) (Storer, error) { type iamConfig struct { Users map[string]types.User `json:"users"` + // AccessKeyIndex maps an access key id to the username that owns it, + // so GetAccessKeyLastUsed can resolve a key without scanning every user. + AccessKeyIndex map[string]string `json:"accessKeyIndex"` } func defaultIAMConfig() iamConfig { - return iamConfig{Users: map[string]types.User{}} + return iamConfig{ + Users: map[string]types.User{}, + AccessKeyIndex: map[string]string{}, + } } func normalizeIAMConfig(conf *iamConfig) { if conf.Users == nil { conf.Users = make(map[string]types.User) } + if conf.AccessKeyIndex == nil { + conf.AccessKeyIndex = make(map[string]string) + } } func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { @@ -100,9 +109,13 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - if _, ok := conf.Users[username]; !ok { + user, ok := conf.Users[username] + if !ok { return nil, iamerr.NoSuchEntityUser(username) } + if len(user.AccessKeys) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } delete(conf.Users, username) return json.Marshal(conf) @@ -214,6 +227,9 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t if user.UserName != input.UserName { delete(conf.Users, input.UserName) + for _, key := range user.AccessKeys { + conf.AccessKeyIndex[key.AccessKeyId] = user.UserName + } } conf.Users[user.UserName] = user updated = user @@ -226,8 +242,212 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return cloneUser(updated), nil } +func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { + s.Lock() + defer s.Unlock() + + var created types.AccessKey + 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) + } + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + if _, ok := conf.AccessKeyIndex[input.AccessKeyID]; ok { + return nil, ErrAccessKeyIDAlreadyExists + } + + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + conf.Users[input.UserName] = user + conf.AccessKeyIndex[input.AccessKeyID] = input.UserName + + created = types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + } + + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return &created, nil +} + +func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKeyInput) 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 + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + found = true + break + } + } + if !found { + return nil, iamerr.NoSuchEntityAccessKey(input.AccessKeyID) + } + + conf.Users[input.UserName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID 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 + } + + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityUser(username) + } + + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + conf.Users[username] = user + delete(conf.AccessKeyIndex, accessKeyID) + + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + username, ok := conf.AccessKeyIndex[accessKeyID] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return &GetAccessKeyLastUsedOutput{ + UserName: username, + LastUsedDate: key.LastUsedDate, + ServiceName: key.LastUsedService, + Region: key.LastUsedRegion, + }, nil + } + } + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) +} + +func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) + for _, key := range user.AccessKeys { + keys = append(keys, types.AccessKeyMetadata{ + UserName: input.UserName, + AccessKeyId: key.AccessKeyId, + Status: key.Status, + CreateDate: key.CreateDate, + }) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i].AccessKeyId < keys[j].AccessKeyId + }) + + start := 0 + if input.Marker != "" { + start = len(keys) + for i, key := range keys { + if key.AccessKeyId == input.Marker { + start = i + 1 + break + } + } + } + keys = keys[start:] + + limit := len(keys) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListAccessKeysOutput{ + AccessKeys: make([]types.AccessKeyMetadata, limit), + } + copy(out.AccessKeys, keys[:limit]) + if limit < len(keys) { + out.IsTruncated = true + out.Marker = out.AccessKeys[limit-1].AccessKeyId + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) + cloned.AccessKeys = slices.Clone(user.AccessKeys) return &cloned } diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index c2544815..7b8eaed9 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -19,13 +19,19 @@ import ( "errors" "fmt" "strings" + "time" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" ) +// MaxAccessKeysPerUser is the maximum number of access keys a single IAM +// user may hold at once, matching the AWS IAM quota. +const MaxAccessKeysPerUser = 2 + var ( - ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") ) type ListUsersInput struct { @@ -47,6 +53,39 @@ type UpdateUserInput struct { NewArn string } +type CreateAccessKeyInput struct { + UserName string + AccessKeyID string + SecretAccessKey string + Status string + CreateDate time.Time +} + +type UpdateAccessKeyInput struct { + UserName string + AccessKeyID string + Status string +} + +type ListAccessKeysInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListAccessKeysOutput struct { + AccessKeys []types.AccessKeyMetadata + IsTruncated bool + Marker string +} + +type GetAccessKeyLastUsedOutput struct { + UserName string + LastUsedDate time.Time + ServiceName string + Region string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -54,6 +93,12 @@ type Storer interface { 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) + + CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) + UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error + DeleteAccessKey(ctx context.Context, username, accessKeyID string) error + GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) + ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index 6947cefa..be0ea36d 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -198,6 +198,22 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("reopened tags = %#v, want %#v", reopenedUser.Tags, users[0].Tags) } + if _, err := reopened.CreateAccessKey(ctx, CreateAccessKeyInput{ + UserName: "zoe", + AccessKeyID: "AKIAZZZZZZZZZZZZZZZZ", + SecretAccessKey: "secret", + Status: "Active", + CreateDate: created, + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflict)) { + t.Fatalf("DeleteUser with access keys err = %v, want DeleteConflict", err) + } + if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAZZZZZZZZZZZZZZZZ"); err != nil { + t.Fatalf("DeleteAccessKey: %v", err) + } + if err := reopened.DeleteUser(ctx, "zoe"); err != nil { t.Fatalf("DeleteUser: %v", err) } diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index de97762b..9c661db9 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "slices" "sort" "strings" "time" @@ -228,9 +229,13 @@ func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User } func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { - if _, err := s.GetUser(ctx, username); err != nil { + user, err := s.GetUser(ctx, username) + if err != nil { return err } + if len(user.AccessKeys) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } return s.deleteByPath(username) } @@ -366,17 +371,186 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty 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 + } else if _, err := s.replaceUser(ctx, *user); err != nil { + return nil, err + } + + return cloneUser(*user), nil +} + +// replaceUser overwrites the stored document for user.UserName by deleting +// all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceUser(ctx context.Context, user types.User) (*types.User, error) { + if err := s.deleteByPath(user.UserName); err != nil { + return nil, err + } + return s.CreateUser(ctx, user) +} + +func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + return nil, ErrAccessKeyIDAlreadyExists } - if _, err := s.CreateUser(ctx, *user); err != nil { + } + + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + + if _, err := s.replaceUser(ctx, *user); err != nil { + return nil, err + } + + return &types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + }, nil +} + +func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return err + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + found = true + break + } + } + if !found { + return iamerr.NoSuchEntityAccessKey(input.AccessKeyID) + } + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error { + user, err := s.GetUser(ctx, username) + if err != nil { + return err + } + + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != 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 nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } return nil, err } } - return cloneUser(*user), nil + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, err + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return &GetAccessKeyLastUsedOutput{ + UserName: username, + LastUsedDate: key.LastUsedDate, + ServiceName: key.LastUsedService, + Region: key.LastUsedRegion, + }, nil + } + } + } + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) +} + +func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) + for _, key := range user.AccessKeys { + keys = append(keys, types.AccessKeyMetadata{ + UserName: input.UserName, + AccessKeyId: key.AccessKeyId, + Status: key.Status, + CreateDate: key.CreateDate, + }) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i].AccessKeyId < keys[j].AccessKeyId + }) + + start := 0 + if input.Marker != "" { + start = len(keys) + for i, key := range keys { + if key.AccessKeyId == input.Marker { + start = i + 1 + break + } + } + } + keys = keys[start:] + + limit := len(keys) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListAccessKeysOutput{ + AccessKeys: make([]types.AccessKeyMetadata, limit), + } + copy(out.AccessKeys, keys[:limit]) + if limit < len(keys) { + out.IsTruncated = true + out.Marker = out.AccessKeys[limit-1].AccessKeyId + } + + return out, nil } // deleteByPath permanently removes a secret and all its versions without diff --git a/iamapi/types/access_key.go b/iamapi/types/access_key.go new file mode 100644 index 00000000..9cc0ddfc --- /dev/null +++ b/iamapi/types/access_key.go @@ -0,0 +1,121 @@ +// 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 CreateAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"` + Result CreateAccessKeyResult `xml:"CreateAccessKeyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateAccessKeyResult struct { + AccessKey AccessKey +} + +type AccessKey struct { + UserName string `xml:",omitempty"` + AccessKeyId string + Status string + SecretAccessKey string + CreateDate time.Time +} + +type UpdateAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetAccessKeyLastUsedResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetAccessKeyLastUsedResponse"` + Result GetAccessKeyLastUsedResult `xml:"GetAccessKeyLastUsedResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetAccessKeyLastUsedResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetAccessKeyLastUsedResult struct { + UserName string `xml:",omitempty"` + AccessKeyLastUsed AccessKeyLastUsed +} + +type AccessKeyLastUsed struct { + LastUsedDate *time.Time `xml:",omitempty"` + ServiceName string + Region string +} + +type ListAccessKeysResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"` + Result ListAccessKeysResult `xml:"ListAccessKeysResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListAccessKeysResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListAccessKeysResult struct { + AccessKeyMetadata AccessKeyMetadataList + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type AccessKeyMetadataList struct { + Members []AccessKeyMetadata `xml:"member"` +} + +type AccessKeyMetadata struct { + UserName string `xml:",omitempty"` + AccessKeyId string + Status string + CreateDate time.Time +} + +// AccessKeyEntry is the storage representation of an access key belonging to +// a User. It is never marshaled to XML directly; it round-trips through JSON +// for the internal and Vault storers. +type AccessKeyEntry struct { + AccessKeyId string + SecretAccessKey string + Status string + CreateDate time.Time + LastUsedDate time.Time + LastUsedService string + LastUsedRegion string +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index 40fdc2a8..9083ea2b 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -99,12 +99,13 @@ func (r *DeleteUserResponse) SetRequestID(requestID string) { } 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"` + 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"` + AccessKeys []AccessKeyEntry `xml:"-"` } type Tag struct { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index a7ace12e..0d3750eb 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1166,6 +1166,7 @@ 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_has_access_keys) ts.Run(IAMDeleteUser_success) } @@ -1181,6 +1182,64 @@ func TestIAMUpdateUser(ts *TestState) { ts.Run(IAMUpdateUser_success) } +func TestIAMCreateAccessKey(ts *TestState) { + ts.Run(IAMCreateAccessKey_missing_user_name) + ts.Run(IAMCreateAccessKey_invalid_user_name) + ts.Run(IAMCreateAccessKey_long_user_name) + ts.Run(IAMCreateAccessKey_non_existing_user) + ts.Run(IAMCreateAccessKey_limit_exceeded) + ts.Run(IAMCreateAccessKey_success) +} + +func TestIAMUpdateAccessKey(ts *TestState) { + ts.Run(IAMUpdateAccessKey_missing_user_name) + ts.Run(IAMUpdateAccessKey_invalid_user_name) + ts.Run(IAMUpdateAccessKey_long_user_name) + ts.Run(IAMUpdateAccessKey_missing_access_key_id) + ts.Run(IAMUpdateAccessKey_access_key_id_too_short) + ts.Run(IAMUpdateAccessKey_access_key_id_too_long) + ts.Run(IAMUpdateAccessKey_invalid_access_key_id_chars) + ts.Run(IAMUpdateAccessKey_missing_status) + ts.Run(IAMUpdateAccessKey_invalid_status) + ts.Run(IAMUpdateAccessKey_non_existing_user) + ts.Run(IAMUpdateAccessKey_non_existing_access_key) + ts.Run(IAMUpdateAccessKey_success) +} + +func TestIAMDeleteAccessKey(ts *TestState) { + ts.Run(IAMDeleteAccessKey_missing_user_name) + ts.Run(IAMDeleteAccessKey_invalid_user_name) + ts.Run(IAMDeleteAccessKey_long_user_name) + ts.Run(IAMDeleteAccessKey_missing_access_key_id) + ts.Run(IAMDeleteAccessKey_access_key_id_too_short) + ts.Run(IAMDeleteAccessKey_access_key_id_too_long) + ts.Run(IAMDeleteAccessKey_invalid_access_key_id_chars) + ts.Run(IAMDeleteAccessKey_non_existing_user) + ts.Run(IAMDeleteAccessKey_non_existing_access_key) + ts.Run(IAMDeleteAccessKey_success) +} + +func TestIAMGetAccessKeyLastUsed(ts *TestState) { + ts.Run(IAMGetAccessKeyLastUsed_missing_access_key_id) + ts.Run(IAMGetAccessKeyLastUsed_access_key_id_too_short) + ts.Run(IAMGetAccessKeyLastUsed_access_key_id_too_long) + ts.Run(IAMGetAccessKeyLastUsed_invalid_access_key_id_chars) + ts.Run(IAMGetAccessKeyLastUsed_non_existing_access_key) + ts.Run(IAMGetAccessKeyLastUsed_success) +} + +func TestIAMListAccessKeys(ts *TestState) { + ts.Run(IAMListAccessKeys_missing_user_name) + ts.Run(IAMListAccessKeys_invalid_user_name) + ts.Run(IAMListAccessKeys_long_user_name) + ts.Run(IAMListAccessKeys_invalid_max_items) + ts.Run(IAMListAccessKeys_invalid_max_items_format) + ts.Run(IAMListAccessKeys_non_existing_user) + ts.Run(IAMListAccessKeys_empty_result) + ts.Run(IAMListAccessKeys_success) + ts.Run(IAMListAccessKeys_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1189,6 +1248,11 @@ func TestIAM(ts *TestState) { TestIAMListUsers(ts) TestIAMDeleteUser(ts) TestIAMUpdateUser(ts) + TestIAMCreateAccessKey(ts) + TestIAMUpdateAccessKey(ts) + TestIAMDeleteAccessKey(ts) + TestIAMGetAccessKeyLastUsed(ts) + TestIAMListAccessKeys(ts) } func TestAccessControl(ts *TestState) { @@ -1573,6 +1637,7 @@ func GetIntTests() IntTests { "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_has_access_keys": IAMDeleteUser_has_access_keys, "IAMDeleteUser_success": IAMDeleteUser_success, "IAMUpdateUser_invalid_user_name": IAMUpdateUser_invalid_user_name, "IAMUpdateUser_long_user_name": IAMUpdateUser_long_user_name, @@ -1583,6 +1648,49 @@ func GetIntTests() IntTests { "IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path, "IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists, "IAMUpdateUser_success": IAMUpdateUser_success, + "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, + "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, + "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, + "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, + "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, + "IAMCreateAccessKey_success": IAMCreateAccessKey_success, + "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, + "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, + "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, + "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, + "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, + "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, + "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, + "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, + "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, + "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, + "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, + "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, + "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, + "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, + "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, + "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, + "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, + "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, + "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, + "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, + "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, + "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, + "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, + "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, + "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, + "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, + "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, + "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, + "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, + "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, + "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, + "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, + "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, + "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, + "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, + "IAMListAccessKeys_success": IAMListAccessKeys_success, + "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, "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_create_access_key.go b/tests/integration/iam_create_access_key.go new file mode 100644 index 00000000..78155743 --- /dev/null +++ b/tests/integration/iam_create_access_key.go @@ -0,0 +1,151 @@ +// 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" + "regexp" + "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" +) + +var integrationIAMAccessKeyIDPattern = regexp.MustCompile(`^AKIA[A-Z2-7]{17}$`) + +func IAMCreateAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{}) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMCreateAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{ + UserName: aws.String("invalid/user"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMCreateAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMCreateAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMCreateAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMCreateAccessKey_limit_exceeded(s *S3Conf) error { + testName := "IAMCreateAccessKey_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + for range 2 { + if _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}); err != nil { + return err + } + } + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.AccessKeysLimitExceeded(2)) + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateAccessKey_success(s *S3Conf) error { + testName := "IAMCreateAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + checkErr := func() error { + if err != nil { + return err + } + return checkCreateAccessKeyOutput(out, userName) + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func createIAMAccessKey(client *iam.Client, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateAccessKey(ctx, input) +} + +func checkCreateAccessKeyOutput(out *iam.CreateAccessKeyOutput, userName string) error { + if out == nil || out.AccessKey == nil { + return fmt.Errorf("expected CreateAccessKey output access key") + } + + key := out.AccessKey + if aws.ToString(key.UserName) != userName { + return fmt.Errorf("expected access key user name to be %q, instead got %q", userName, aws.ToString(key.UserName)) + } + if !integrationIAMAccessKeyIDPattern.MatchString(aws.ToString(key.AccessKeyId)) { + return fmt.Errorf("expected AWS IAM access key id, instead got %q", aws.ToString(key.AccessKeyId)) + } + if key.Status != iamtypes.StatusTypeActive { + return fmt.Errorf("expected access key status to be %q, instead got %q", iamtypes.StatusTypeActive, key.Status) + } + if aws.ToString(key.SecretAccessKey) == "" { + return fmt.Errorf("expected access key secret") + } + if key.CreateDate == nil || key.CreateDate.IsZero() { + return fmt.Errorf("expected access key create date") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected CreateAccessKey response request id") + } + + return nil +} diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go index 12a0a62e..9f64cae1 100644 --- a/tests/integration/iam_create_user.go +++ b/tests/integration/iam_create_user.go @@ -228,6 +228,23 @@ func deleteIAMUser(client *iam.Client, userName string) error { return err } +// deleteIAMUserAndAccessKeys deletes all of the user's access keys before +// deleting the user, since DeleteUser rejects users with access keys still +// attached. Use this for test cleanup after a test has created access keys; +// use deleteIAMUser directly when the test itself manages key deletion. +func deleteIAMUserAndAccessKeys(client *iam.Client, userName string) error { + out, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + for _, key := range out.AccessKeyMetadata { + if err := deleteIAMAccessKey(client, userName, aws.ToString(key.AccessKeyId)); err != nil { + return err + } + } + return deleteIAMUser(client, userName) +} + func newIAMUserName() string { return "create-user-" + genRandString(16) } diff --git a/tests/integration/iam_delete_access_key.go b/tests/integration/iam_delete_access_key.go new file mode 100644 index 00000000..36897bfb --- /dev/null +++ b/tests/integration/iam_delete_access_key.go @@ -0,0 +1,169 @@ +// 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" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "", genRandString(20)) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMDeleteAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "invalid/user", genRandString(20)) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMDeleteAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, strings.Repeat("a", 129), genRandString(20)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMDeleteAccessKey_missing_access_key_id(s *S3Conf) error { + testName := "IAMDeleteAccessKey_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"DeleteAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMDeleteAccessKey_access_key_id_too_short(s *S3Conf) error { + testName := "IAMDeleteAccessKey_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", genRandString(15)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMDeleteAccessKey_access_key_id_too_long(s *S3Conf) error { + testName := "IAMDeleteAccessKey_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", genRandString(129)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMDeleteAccessKey_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMDeleteAccessKey_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", "invalid-key-id-1234") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMDeleteAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMDeleteAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + err := deleteIAMAccessKey(client, userName, genRandString(20)) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMDeleteAccessKey_non_existing_access_key(s *S3Conf) error { + testName := "IAMDeleteAccessKey_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + accessKeyID := genRandString(20) + deleteErr := deleteIAMAccessKey(client, userName, accessKeyID) + checkErr := checkIAMApiErr(deleteErr, iamerr.NoSuchEntityAccessKey(accessKeyID)) + + userDeleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return userDeleteErr + }) +} + +func IAMDeleteAccessKey_success(s *S3Conf) error { + testName := "IAMDeleteAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + if err := deleteIAMAccessKey(client, userName, accessKeyID); err != nil { + return err + } + + _, err = getIAMAccessKeyLastUsed(client, accessKeyID) + return checkIAMApiErr(err, iamerr.NoSuchEntityAccessKey(accessKeyID)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func deleteIAMAccessKey(client *iam.Client, userName, accessKeyID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + input := &iam.DeleteAccessKeyInput{AccessKeyId: &accessKeyID} + if userName != "" { + input.UserName = &userName + } + _, err := client.DeleteAccessKey(ctx, input) + return err +} diff --git a/tests/integration/iam_delete_user.go b/tests/integration/iam_delete_user.go index 1271b1dd..f618101c 100644 --- a/tests/integration/iam_delete_user.go +++ b/tests/integration/iam_delete_user.go @@ -47,6 +47,35 @@ func IAMDeleteUser_non_existing_user(s *S3Conf) error { }) } +func IAMDeleteUser_has_access_keys(s *S3Conf) error { + testName := "IAMDeleteUser_has_access_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(out.AccessKey.AccessKeyId) + + checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflict)) + + deleteKeyErr := deleteIAMAccessKey(client, userName, accessKeyID) + deleteUserErr := deleteIAMUser(client, userName) + + if checkErr != nil { + return checkErr + } + if deleteKeyErr != nil { + return deleteKeyErr + } + return deleteUserErr + }) +} + func IAMDeleteUser_success(s *S3Conf) error { testName := "IAMDeleteUser_success" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_get_access_key_last_used.go b/tests/integration/iam_get_access_key_last_used.go new file mode 100644 index 00000000..2a496f8a --- /dev/null +++ b/tests/integration/iam_get_access_key_last_used.go @@ -0,0 +1,137 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetAccessKeyLastUsed_missing_access_key_id(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"GetAccessKeyLastUsed"}, + "Version": {"2010-05-08"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMGetAccessKeyLastUsed_access_key_id_too_short(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, genRandString(15)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMGetAccessKeyLastUsed_access_key_id_too_long(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, genRandString(129)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMGetAccessKeyLastUsed_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, "invalid-key-id-1234") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMGetAccessKeyLastUsed_non_existing_access_key(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + accessKeyID := genRandString(20) + _, err := getIAMAccessKeyLastUsed(client, accessKeyID) + return checkIAMApiErr(err, iamerr.NoSuchEntityAccessKey(accessKeyID)) + }) +} + +func IAMGetAccessKeyLastUsed_success(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + out, err := getIAMAccessKeyLastUsed(client, accessKeyID) + if err != nil { + return err + } + if out == nil || out.AccessKeyLastUsed == nil { + return fmt.Errorf("expected GetAccessKeyLastUsed output") + } + if aws.ToString(out.UserName) != userName { + return fmt.Errorf("expected access key user name to be %q, instead got %q", userName, aws.ToString(out.UserName)) + } + if aws.ToString(out.AccessKeyLastUsed.ServiceName) != "N/A" { + return fmt.Errorf("expected access key last used service name to be %q, instead got %q", "N/A", aws.ToString(out.AccessKeyLastUsed.ServiceName)) + } + if aws.ToString(out.AccessKeyLastUsed.Region) != "N/A" { + return fmt.Errorf("expected access key last used region to be %q, instead got %q", "N/A", aws.ToString(out.AccessKeyLastUsed.Region)) + } + if out.AccessKeyLastUsed.LastUsedDate != nil { + return fmt.Errorf("expected no access key last used date, instead got %v", out.AccessKeyLastUsed.LastUsedDate) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetAccessKeyLastUsed response request id") + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMAccessKeyLastUsed(client *iam.Client, accessKeyID string) (*iam.GetAccessKeyLastUsedOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetAccessKeyLastUsed(ctx, &iam.GetAccessKeyLastUsedInput{AccessKeyId: &accessKeyID}) +} diff --git a/tests/integration/iam_list_access_keys.go b/tests/integration/iam_list_access_keys.go new file mode 100644 index 00000000..87efa59b --- /dev/null +++ b/tests/integration/iam_list_access_keys.go @@ -0,0 +1,331 @@ +// 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" + "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 IAMListAccessKeys_missing_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{}) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMListAccessKeys_invalid_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: aws.String("invalid/user"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMListAccessKeys_long_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMListAccessKeys_invalid_max_items(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + for _, maxItems := range []int32{-1, 0, 1001} { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: &userName, + MaxItems: aws.Int32(maxItems), + }) + expected := iamerr.InvalidMaxItems(fmt.Sprint(maxItems)) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListAccessKeys_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListAccessKeys"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "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 IAMListAccessKeys_non_existing_user(s *S3Conf) error { + testName := "IAMListAccessKeys_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListAccessKeys_empty_result(s *S3Conf) error { + testName := "IAMListAccessKeys_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if err := checkIAMListAccessKeysOutput(out); err != nil { + return err + } + if len(out.AccessKeyMetadata) != 0 { + return fmt.Errorf("expected no access keys, instead got %d", len(out.AccessKeyMetadata)) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListAccessKeys_success(s *S3Conf) error { + testName := "IAMListAccessKeys_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + expected := map[string]iamtypes.StatusType{} + for range 2 { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + expected[aws.ToString(created.AccessKey.AccessKeyId)] = iamtypes.StatusTypeActive + } + + first, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + second, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if err := checkIAMListAccessKeysOutput(first); err != nil { + return err + } + if err := checkIAMListAccessKeys(first.AccessKeyMetadata, userName, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListAccessKeyIDs(first.AccessKeyMetadata), iamListAccessKeyIDs(second.AccessKeyMetadata)) { + return fmt.Errorf("expected consistent results across calls") + } + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListAccessKeys_pagination(s *S3Conf) error { + testName := "IAMListAccessKeys_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + expected := map[string]iamtypes.StatusType{} + for range 2 { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + expected[aws.ToString(created.AccessKey.AccessKeyId)] = iamtypes.StatusTypeActive + } + + input := iam.ListAccessKeysInput{UserName: &userName, MaxItems: aws.Int32(1)} + firstPages, err := collectIAMListAccessKeyPages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListAccessKeyPages(client, input) + if err != nil { + return err + } + if len(firstPages) != 2 { + return fmt.Errorf("expected 2 pages, instead got %d", len(firstPages)) + } + var allKeys []iamtypes.AccessKeyMetadata + for i, page := range firstPages { + if len(page.AccessKeyMetadata) != 1 { + return fmt.Errorf("expected page %d to contain 1 access key, instead got %d", i+1, len(page.AccessKeyMetadata)) + } + if page.IsTruncated != (i < len(firstPages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + allKeys = append(allKeys, page.AccessKeyMetadata...) + } + if err := checkIAMListAccessKeys(allKeys, userName, expected); err != nil { + return err + } + + var firstIDs, secondIDs [][]string + for _, page := range firstPages { + firstIDs = append(firstIDs, append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListAccessKeyIDs(page.AccessKeyMetadata)...)) + } + for _, page := range secondPages { + secondIDs = append(secondIDs, append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListAccessKeyIDs(page.AccessKeyMetadata)...)) + } + if !reflect.DeepEqual(firstIDs, secondIDs) { + return fmt.Errorf("expected consistent pagination results") + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMAccessKeys(client *iam.Client, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListAccessKeys(ctx, input) +} + +func collectIAMListAccessKeyPages(client *iam.Client, input iam.ListAccessKeysInput) ([]*iam.ListAccessKeysOutput, error) { + var pages []*iam.ListAccessKeysOutput + for { + out, err := listIAMAccessKeys(client, &input) + if err != nil { + return nil, err + } + if err := checkIAMListAccessKeysOutput(out); err != nil { + return nil, err + } + pages = append(pages, out) + if !out.IsTruncated { + return pages, nil + } + input.Marker = out.Marker + } +} + +func checkIAMListAccessKeysOutput(out *iam.ListAccessKeysOutput) error { + if out == nil { + return fmt.Errorf("expected ListAccessKeys output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListAccessKeys response request id") + } + if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") { + return fmt.Errorf("expected marker only when ListAccessKeys output is truncated") + } + for _, key := range out.AccessKeyMetadata { + if aws.ToString(key.UserName) == "" || aws.ToString(key.AccessKeyId) == "" || key.CreateDate == nil || key.CreateDate.IsZero() { + return fmt.Errorf("expected all required fields for listed access key, instead got %#v", key) + } + if !integrationIAMAccessKeyIDPattern.MatchString(aws.ToString(key.AccessKeyId)) { + return fmt.Errorf("expected AWS IAM access key id, instead got %q", aws.ToString(key.AccessKeyId)) + } + } + return nil +} + +func checkIAMListAccessKeys(keys []iamtypes.AccessKeyMetadata, userName string, expected map[string]iamtypes.StatusType) error { + if len(keys) != len(expected) { + return fmt.Errorf("expected %d access keys, instead got %d: %v", len(expected), len(keys), iamListAccessKeyIDs(keys)) + } + ids := iamListAccessKeyIDs(keys) + if !sort.StringsAreSorted(ids) { + return fmt.Errorf("expected access keys sorted by access key id, instead got %v", ids) + } + for _, key := range keys { + id := aws.ToString(key.AccessKeyId) + status, ok := expected[id] + if !ok { + return fmt.Errorf("unexpected listed access key %q", id) + } + if aws.ToString(key.UserName) != userName { + return fmt.Errorf("expected access key %q user name %q, instead got %q", id, userName, aws.ToString(key.UserName)) + } + if key.Status != status { + return fmt.Errorf("expected access key %q status %q, instead got %q", id, status, key.Status) + } + } + return nil +} + +func iamListAccessKeyIDs(keys []iamtypes.AccessKeyMetadata) []string { + ids := make([]string, len(keys)) + for i, key := range keys { + ids[i] = aws.ToString(key.AccessKeyId) + } + return ids +} diff --git a/tests/integration/iam_update_access_key.go b/tests/integration/iam_update_access_key.go new file mode 100644 index 00000000..0170054b --- /dev/null +++ b/tests/integration/iam_update_access_key.go @@ -0,0 +1,254 @@ +// 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" + "net/url" + "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 IAMUpdateAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMUpdateAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("invalid/user"), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMUpdateAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String(strings.Repeat("a", 129)), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMUpdateAccessKey_missing_access_key_id(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"UpdateAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Status": {"Active"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMUpdateAccessKey_access_key_id_too_short(s *S3Conf) error { + testName := "IAMUpdateAccessKey_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(15)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMUpdateAccessKey_access_key_id_too_long(s *S3Conf) error { + testName := "IAMUpdateAccessKey_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(129)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMUpdateAccessKey_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String("invalid-key-id-1234"), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMUpdateAccessKey_missing_status(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_status" + body := []byte(url.Values{ + "Action": {"UpdateAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "AccessKeyId": {genRandString(20)}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("Status")) + }) +} + +func IAMUpdateAccessKey_invalid_status(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_status" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusType("Bogus"), + }) + return checkIAMApiErr(err, iamerr.InvalidAccessKeyStatus("Bogus")) + }) +} + +func IAMUpdateAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMUpdateAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMUpdateAccessKey_non_existing_access_key(s *S3Conf) error { + testName := "IAMUpdateAccessKey_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + accessKeyID := genRandString(20) + _, updateErr := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: &accessKeyID, + Status: iamtypes.StatusTypeActive, + }) + checkErr := checkIAMApiErr(updateErr, iamerr.NoSuchEntityAccessKey(accessKeyID)) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateAccessKey_success(s *S3Conf) error { + testName := "IAMUpdateAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + out, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: &accessKeyID, + Status: iamtypes.StatusTypeInactive, + }) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected UpdateAccessKey output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UpdateAccessKey response request id") + } + + listOut, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if len(listOut.AccessKeyMetadata) != 1 { + return fmt.Errorf("expected 1 access key, instead got %d", len(listOut.AccessKeyMetadata)) + } + if listOut.AccessKeyMetadata[0].Status != iamtypes.StatusTypeInactive { + return fmt.Errorf("expected access key status to be %q, instead got %q", iamtypes.StatusTypeInactive, listOut.AccessKeyMetadata[0].Status) + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateIAMAccessKey(client *iam.Client, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UpdateAccessKey(ctx, input) +} From 3328501feae46cb30fc622250de3e94766093fc4 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Fri, 10 Jul 2026 03:17:23 +0400 Subject: [PATCH 03/10] feat: add IAM user inline policy CRUD Add support for AWS-compatible inline identity-based policies on IAM users, implementing the `PutUserPolicy`, `GetUserPolicy`, `DeleteUserPolicy`, and `ListUserPolicies` actions on both the internal and Vault storage backends. - iamapi/policy is a new package that parses and validates policy documents against IAM's parameter-level constraints (max length, allowed charset) and policy grammar (Version, Effect, mutually exclusive Action/NotAction and Resource/NotResource, vendor-prefixed actions, ARN-shaped resources, no Principal/NotPrincipal, unique Sids). - `PutUserPolicy` creates or replaces a named inline policy on a user, enforcing a 2048-byte aggregate quota across all of a user's inline policies (MaxInlinePolicyBytesPerUser), matching the AWS IAM quota. - `GetUserPolicy` returns a policy's document RFC 3986 percent-encoded, matching how real IAM encodes the PolicyDocument response element. - `DeleteUserPolicy` removes a named inline policy from a user. - `ListUserPolicies` returns a paginated, sorted list of a user's inline policy names, honoring Marker/MaxItems like the other IAM list APIs. - `DeleteUser` is now rejected with a DeleteConflict error if the user still has inline policies attached, mirroring the existing access-key delete-conflict behavior. --- iamapi/controller.go | 216 +++++++---- iamapi/controller_test.go | 369 +++++++++++++++++++ iamapi/iamerr/errors.go | 38 +- iamapi/internal/iamutil/policy.go | 29 ++ iamapi/internal/iamutil/user.go | 57 ++- iamapi/policy/document.go | 103 ++++++ iamapi/policy/document_test.go | 114 ++++++ iamapi/policy/validate.go | 227 ++++++++++++ iamapi/policy/validate_test.go | 123 +++++++ iamapi/router.go | 5 + iamapi/storage/internal.go | 158 ++++++++ iamapi/storage/storer.go | 27 ++ iamapi/storage/vault.go | 119 +++++++ iamapi/types/policy.go | 96 +++++ iamapi/types/user.go | 1 + tests/integration/group-tests.go | 74 ++++ tests/integration/iam_delete_user_policy.go | 203 +++++++++++ tests/integration/iam_get_user_policy.go | 165 +++++++++ tests/integration/iam_list_user_policies.go | 223 ++++++++++++ tests/integration/iam_put_user_policy.go | 376 ++++++++++++++++++++ 20 files changed, 2645 insertions(+), 78 deletions(-) create mode 100644 iamapi/internal/iamutil/policy.go create mode 100644 iamapi/policy/document.go create mode 100644 iamapi/policy/document_test.go create mode 100644 iamapi/policy/validate.go create mode 100644 iamapi/policy/validate_test.go create mode 100644 iamapi/types/policy.go create mode 100644 tests/integration/iam_delete_user_policy.go create mode 100644 tests/integration/iam_get_user_policy.go create mode 100644 tests/integration/iam_list_user_policies.go create mode 100644 tests/integration/iam_put_user_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index 9718836e..ef4b5086 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,13 +17,13 @@ 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/policy" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/iamapi/types" ) @@ -37,12 +37,8 @@ func NewController(store storage.Storer) IAMApiController { } 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 { + userName, err := iamutil.GetUserName(ctx, "CreateUser", iamutil.MaxUserNameLen, iamerr.MissingValue("userName")) + if err != nil { return nil, err } @@ -95,12 +91,8 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { } 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 { + username, err := iamutil.GetUserName(ctx, "DeleteUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -126,7 +118,7 @@ func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) { }}, }}, nil } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err } @@ -150,14 +142,9 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { 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) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUsers") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -181,12 +168,8 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { } 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 { + username, err := iamutil.GetUserName(ctx, "UpdateUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -198,7 +181,7 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") if newUserName != "" { - if err := iamutil.ValidateUserName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { + if err := iamutil.ValidateName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { return nil, err } } @@ -235,12 +218,8 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required CreateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -277,18 +256,14 @@ func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { }, nil } - err := fmt.Errorf("generate IAM access key id: exhausted collision retries") + err = fmt.Errorf("generate IAM access key id: exhausted collision retries") debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) return nil, err } func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required UpdateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "UpdateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -323,12 +298,8 @@ func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required DeleteAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "DeleteAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -392,23 +363,14 @@ func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error) } func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required ListAccessKeys parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "ListAccessKeys", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if 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 ListAccessKeys MaxItems value %q: parse_error=%v", rawMaxItems, err) - return nil, iamerr.InvalidMaxItems(rawMaxItems) - } - maxItems = int32(parsed) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListAccessKeys") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -430,3 +392,133 @@ func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) PutUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "PutUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + // Confirm the user exists before inspecting policy document content + if _, err := c.store.GetUser(ctx.Context(), userName); err != nil { + debuglogger.Logf("failed to get IAM user %q for PutUserPolicy: %v", userName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutUserPolicy(ctx.Context(), storage.PutUserPolicyInput{ + UserName: userName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.PutUserPolicyResponse{}}, nil +} + +func (c IAMApiController) GetUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "GetUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetUserPolicy(ctx.Context(), userName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.GetUserPolicyResponse{ + Result: types.GetUserPolicyResult{ + UserName: userName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "DeleteUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteUserPolicy(ctx.Context(), userName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.DeleteUserPolicyResponse{}}, nil +} + +func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "ListUserPolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserPolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListUserPolicies(ctx.Context(), storage.ListUserPoliciesInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM user policies for user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListUserPoliciesResponse{ + Result: types.ListUserPoliciesResult{ + PolicyNames: types.PolicyNameList{Members: out.PolicyNames}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 88b59f45..58cf4db8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" @@ -478,6 +479,355 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.") } +func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createUser := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"alice"}, + }) + if createUser.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", createUser.StatusCode, readBody(t, createUser)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutUserPolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutUserPolicyResponse" { + t.Fatalf("PutUserPolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutUserPolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetUserPolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.UserName != "alice" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetUserPolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument) + } + decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if decoded != policyDoc { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListUserPolicies"}, + "UserName": {"alice"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListUserPolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListUserPoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListUserPolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListUserPolicies IsTruncated = true, want false") + } + + // Re-Put-ing the same PolicyName replaces it rather than erroring or + // stacking toward the aggregate size quota. + overwritePut := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutUserPolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut) + overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if !strings.Contains(overwriteDecoded, "Deny") { + t.Fatalf("GetUserPolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteUserPolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteUserPolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteUserPolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") + + // A second delete of the same (now-gone) policy is a hard error, not an + // idempotent success. + doubleDelete := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupUser bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing user name", + params: url.Values{"Action": {"PutUserPolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "put long policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "put non-ascii policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.", + }, + { + name: "put user does not exist", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put nonexistent user wins over malformed document", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": { + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, + }}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Policy document should not specify a principal.", + }, + { + name: "put policy document exceeds aggregate size quota", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {oversizedDoc}}, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Maximum policy size of 2048 bytes exceeded for user alice", + }, + { + name: "get user does not exist", + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupUser: true, + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete user does not exist", + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupUser: true, + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list user does not exist", + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupUser: true, + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupUser { + resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerPutUserPolicyOversizedDocument(t *testing.T) { + // A >131072 byte PolicyDocument does not fit in a GET query string + // against this test server's header/URL read-buffer limit, matching + // real IAM's own guidance to use POST rather than GET for large + // policy documents - so this one case is exercised over POST directly + // rather than through the doIAMAction GET helper used elsewhere. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {strings.Repeat("x", 131073)}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072") +} + +func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deletePolicyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deletePolicyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + // When both an access key and a policy are attached, the policy + // conflict is reported first. + createKey := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"alice"}}) + if createKey.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey status = %d, body=%s", createKey.StatusCode, readBody(t, createKey)) + } + deleteBoth := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteBoth, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteKeyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() @@ -506,6 +856,25 @@ func doIAMAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Re return resp } +// doIAMActionPost signs and sends params as a POST form body rather than a +// GET query string, for requests too large to fit a GET request's +// header/URL buffer (e.g. an oversized PolicyDocument). +func doIAMActionPost(t *testing.T, server *IAMApiServer, params url.Values) *http.Response { + t.Helper() + if !params.Has("Version") { + params.Set("Version", iamAPIVersion) + } + + req := signedIAMRequest(t, http.MethodPost, "http://example.com/", []byte(params.Encode()), testRoot.Secret) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + 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() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index f3c2d001..5c55c6dd 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -54,12 +54,12 @@ const ( ErrInvalidClientTokenID ErrInvalidContentLength ErrThrottling - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys ErrInvalidAccessKeyIDChars ErrDeleteConflict + ErrDeleteConflictPolicies ) type APIError interface { @@ -207,12 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{ 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", @@ -243,6 +237,12 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Cannot delete entity, must delete access keys first.", HTTPStatusCode: http.StatusConflict, }, + ErrDeleteConflictPolicies: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete policies first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -405,6 +405,30 @@ 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 MissingValue(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must not be null", field)) +} + +func ValueTooLong(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 InvalidCharset(field string) Error { + return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) +} + +func MalformedPolicyDocument(message string) Error { + return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) +} + +func NoSuchEntityUserPolicy(userName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) +} + +func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/policy.go b/iamapi/internal/iamutil/policy.go new file mode 100644 index 00000000..9636a12e --- /dev/null +++ b/iamapi/internal/iamutil/policy.go @@ -0,0 +1,29 @@ +// 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 ( + "net/url" + "strings" +) + +// EncodePolicyDocument RFC 3986 percent-encodes a policy document string +// the way real IAM encodes the PolicyDocument element of GetUserPolicy (and +// will for GetRolePolicy) responses: every character outside the unreserved +// set is percent-encoded, with the space character encoded as %20 rather +// than the "+" that url.QueryEscape alone would produce. +func EncodePolicyDocument(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 19bcb921..68bcb667 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -19,6 +19,7 @@ import ( "fmt" "math/big" "regexp" + "strconv" "strings" "github.com/gofiber/fiber/v3" @@ -43,9 +44,9 @@ const ( ) 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}_.:/=+\-@]*$`) + namePattern = 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. @@ -63,6 +64,42 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) { return "", false } +// GetUserName resolves the UserName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +// operation is included in the debug log on failure (e.g. "DeleteUser"). +// missingErr lets callers match the exact AWS error their operation is +// verified against (e.g. iamerr.MissingValue vs iamerr.MissingParameter). +func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + userName, ok := RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required %s parameter: UserName", operation) + return "", missingErr + } + if err := ValidateName("userName", userName, maxLen); err != nil { + return "", err + } + + return userName, nil +} + +// ParseMaxItems reads the MaxItems request parameter, defaulting to +// DefaultMaxItems when absent. operation is included in the debug log on +// parse failure (e.g. "ListUsers", "ListAccessKeys"). +func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) { + rawMaxItems, ok := RequestParam(ctx, "MaxItems") + if !ok || rawMaxItems == "" { + return int32(DefaultMaxItems), nil + } + + parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) + if err != nil || parsed < 1 || parsed > MaxListItems { + debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err) + return 0, iamerr.InvalidMaxItems(rawMaxItems) + } + + return int32(parsed), nil +} + // 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 @@ -106,14 +143,16 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { 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) +// ValidateName checks that name (an IAM identity or policy name, e.g. +// userName or policyName) is non-empty, matches the allowed character set, +// and fits within maxLength. +func ValidateName(field, name string, maxLength int) error { + if len(name) > maxLength { + debuglogger.Logf("IAM name exceeds maximum length: field=%s length=%d max=%d", field, len(name), maxLength) return iamerr.UserNameTooLong(field, maxLength) } - if userName == "" || !userNamePattern.MatchString(userName) { - debuglogger.Logf("invalid IAM user name: field=%s value=%q", field, userName) + if name == "" || !namePattern.MatchString(name) { + debuglogger.Logf("invalid IAM name: field=%s value=%q", field, name) return iamerr.InvalidUserName(field) } diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go new file mode 100644 index 00000000..39a7bc04 --- /dev/null +++ b/iamapi/policy/document.go @@ -0,0 +1,103 @@ +// 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 policy + +import ( + "bytes" + "encoding/json" +) + +// Recognized values for a policy document's Version element. +const ( + Version2008 = "2008-10-17" + Version2012 = "2012-10-17" +) + +// Document is a parsed AWS IAM policy document. +type Document struct { + Version string + Statement []Statement +} + +// Statement is a single element of a policy document's Statement list. +type Statement struct { + Sid string + Effect string + Action StringOrSlice + NotAction StringOrSlice + Resource StringOrSlice + NotResource StringOrSlice + Principal json.RawMessage + NotPrincipal json.RawMessage +} + +// UnmarshalJSON accepts Statement as either a single JSON object or an +// array of objects, matching the AWS IAM policy grammar. A missing or +// JSON-null Statement leaves Document.Statement nil rather than erroring +// here — Validate reports that as a grammar error so all "empty document" +// shapes produce the same message. +func (d *Document) UnmarshalJSON(data []byte) error { + var raw struct { + Version string + Statement json.RawMessage + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + d.Version = raw.Version + + if len(raw.Statement) == 0 || string(bytes.TrimSpace(raw.Statement)) == "null" { + return nil + } + + var stmts []Statement + if err := json.Unmarshal(raw.Statement, &stmts); err == nil { + d.Statement = stmts + return nil + } + + var single Statement + if err := json.Unmarshal(raw.Statement, &single); err != nil { + return err + } + d.Statement = []Statement{single} + return nil +} + +// StringOrSlice decodes a JSON value that may be either a single string or +// an array of strings, matching the AWS IAM policy grammar for Action, +// NotAction, Resource, and NotResource. A JSON-null value decodes to a nil +// StringOrSlice, identical to the key being absent. +type StringOrSlice []string + +func (s *StringOrSlice) UnmarshalJSON(data []byte) error { + if string(bytes.TrimSpace(data)) == "null" { + *s = nil + return nil + } + + var single string + if err := json.Unmarshal(data, &single); err == nil { + *s = StringOrSlice{single} + return nil + } + + var multi []string + if err := json.Unmarshal(data, &multi); err != nil { + return err + } + *s = StringOrSlice(multi) + return nil +} diff --git a/iamapi/policy/document_test.go b/iamapi/policy/document_test.go new file mode 100644 index 00000000..bf9437b2 --- /dev/null +++ b/iamapi/policy/document_test.go @@ -0,0 +1,114 @@ +// 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 policy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestStringOrSliceUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want StringOrSlice + }{ + {"single string", `"s3:GetObject"`, StringOrSlice{"s3:GetObject"}}, + {"array of strings", `["s3:GetObject","s3:PutObject"]`, StringOrSlice{"s3:GetObject", "s3:PutObject"}}, + {"empty array", `[]`, StringOrSlice{}}, + {"empty string", `""`, StringOrSlice{""}}, + {"null", `null`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got StringOrSlice + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Unmarshal() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDocumentUnmarshalJSON(t *testing.T) { + t.Run("statement as array", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement as single object", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement absent leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17"}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("statement null leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":null}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("version absent leaves empty string, not defaulted", func(t *testing.T) { + // Unlike auth's S3 bucket-policy engine (which defaults a missing + // Version to 2008-10-17), real IAM leaves an omitted Version on an + // identity policy exactly as submitted - no default is injected. + var doc Document + err := json.Unmarshal([]byte(`{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Version != "" { + t.Fatalf("Version = %q, want empty", doc.Version) + } + }) + + t.Run("top-level non-object is an unmarshal error", func(t *testing.T) { + var doc Document + if err := json.Unmarshal([]byte(`"hello"`), &doc); err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) +} diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go new file mode 100644 index 00000000..8e14b06b --- /dev/null +++ b/iamapi/policy/validate.go @@ -0,0 +1,227 @@ +// 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 policy + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// MaxDocumentLength is IAM's parameter-level maximum length for a +// PolicyDocument value. +const MaxDocumentLength = 131072 + +// vendorPattern is the inferred grammar for the service prefix of a policy +// action/resource (the text before the first ':', e.g. "s3", "iam", +// "elasticloadbalancing"). AWS does not publish this pattern; alphanumeric +// + hyphen matches every real service prefix and was verified to reject an +// empty or space-containing prefix the same way live IAM does. +var vendorPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +// validPartition is the only ARN partition name supported byt the gateway: real +// IAM also accepts "aws-cn", "aws-us-gov", and the "aws-iso*" partitions, +// but this deployment only ever runs in the standard "aws" partition, so a +// resource ARN whose partition field is anything else is rejected +const validPartition = "aws" + +var ( + errSyntax = iamerr.MalformedPolicyDocument("Syntax errors in policy.") + errMissingActions = iamerr.MalformedPolicyDocument("Policy statement must contain actions.") + errMissingResources = iamerr.MalformedPolicyDocument("Policy statement must contain resources.") + errPrincipalNotAllowed = iamerr.MalformedPolicyDocument("Policy document should not specify a principal.") + errDuplicateSid = iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.") + errMissingVendorPrefix = iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.") + errLegacyParsing = iamerr.MalformedPolicyDocument("The policy failed legacy parsing") +) + +// Validate checks raw against IAM's parameter-level constraints for a +// PolicyDocument value: a maximum length of 131072 and the allowed +// character set (tab/LF/CR plus printable Latin-1, U+0020-U+00FF, with at +// least one such character present — so an empty value is rejected here +// too, as a charset violation). +func Validate(field, raw string) error { + if len(raw) > MaxDocumentLength { + return iamerr.ValueTooLong(field, MaxDocumentLength) + } + if !isValidDocumentCharset(raw) { + return iamerr.InvalidCharset(field) + } + return nil +} + +func isValidDocumentCharset(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || r > 0xFF { + return false + } + } + return true +} + +// Parse parses raw as an IAM policy document and checks it against IAM +// policy grammar +func Parse(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errSyntax + } + return doc.Validate() +} + +// Validate checks d against IAM policy document grammar: a valid Version if +// present, a non-empty Statement (single object or array), document-wide +// unique Sids, and per statement, the rules enforced by Statement.Validate. +func (d Document) Validate() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errSyntax + } + if len(d.Statement) == 0 { + return errSyntax + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.Validate(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// Validate checks s against IAM policy statement grammar: a valid Effect, +// no Principal/NotPrincipal, an Action or NotAction (not both) with +// vendor-prefixed values, and a Resource or NotResource (not both) with +// ARN-shaped values. Condition is not modeled or validated. +func (s Statement) Validate() error { + switch s.Effect { + case "Allow", "Deny": + default: + return errSyntax + } + + if len(s.Principal) > 0 || len(s.NotPrincipal) > 0 { + return errPrincipalNotAllowed + } + + if len(s.Action) > 0 && len(s.NotAction) > 0 { + return errSyntax + } + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errMissingActions + } + for _, action := range s.Action { + if err := validateActionVendor(action); err != nil { + return err + } + } + for _, action := range s.NotAction { + if err := validateActionVendor(action); err != nil { + return err + } + } + + if len(s.Resource) > 0 && len(s.NotResource) > 0 { + return errSyntax + } + if len(s.Resource) == 0 && len(s.NotResource) == 0 { + return errMissingResources + } + for _, resource := range s.Resource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + for _, resource := range s.NotResource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + + return nil +} + +// validateActionVendor checks that action is either the bare wildcard "*" +// or has a syntactically valid "vendor:name" shape. The action name after +// the colon is not checked against any known service/action list — real +// IAM accepts unrecognized service/action names at this stage too. +func validateActionVendor(action string) error { + if action == "*" { + return nil + } + before, _, ok := strings.Cut(action, ":") + if !ok { + return errMissingVendorPrefix + } + vendor := before + if !vendorPattern.MatchString(vendor) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Vendor %s is not valid", vendor)) + } + return nil +} + +// validateResourceARN checks a single Resource/NotResource entry against +// IAM's ARN grammar: either the bare wildcard "*", or +// "arn:partition:service:region:account:resource". The service, region, +// account, and resource fields are not further validated — only the +// partition is checked, matching what real IAM enforces at this stage +func validateResourceARN(resource string) error { + if resource == "*" { + return nil + } + if !strings.Contains(resource, ":") { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Resource %s must be in ARN format or \"*\".", resource)) + } + + if strings.HasPrefix(resource, "arn:") { + fields := strings.SplitN(resource[len("arn:"):], ":", 5) + if len(fields) < 5 { + return errLegacyParsing + } + partition := fields[0] + if partition != validPartition { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, resource)) + } + return nil + } + + tokens := strings.SplitN(resource, ":", 6) + field := func(i int) string { + if i < len(tokens) { + return tokens[i] + } + return "*" + } + partition := field(1) + reconstructed := fmt.Sprintf("arn:%s:%s:%s:%s:%s", partition, field(2), field(3), field(4), field(5)) + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, reconstructed)) +} diff --git a/iamapi/policy/validate_test.go b/iamapi/policy/validate_test.go new file mode 100644 index 00000000..8019a6ee --- /dev/null +++ b/iamapi/policy/validate_test.go @@ -0,0 +1,123 @@ +// 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 policy + +import ( + "errors" + "strings" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account. +func TestValidate(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means Validate must succeed + }{ + {"valid single statement", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid statement as single object, not array", `{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid bare wildcard action and resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`, nil}, + {"valid NotAction alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid NotResource alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"*"}]}`, nil}, + {"valid unrecognized vendor/action accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"totallyfakeservice:DoSomething","Resource":"*"}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil}, + {"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errSyntax}, + {"empty object", `{}`, errSyntax}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"missing statement", `{"Version":"2012-10-17"}`, errSyntax}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, errSyntax}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errSyntax}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, errSyntax}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, errSyntax}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, errSyntax}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, errSyntax}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, errMissingActions}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, errMissingResources}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, errMissingResources}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, errMissingVendorPrefix}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, errMissingVendorPrefix}, + + {"principal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + {"notprincipal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, errDuplicateSid}, + + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, errLegacyParsing}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + {"notresource with invalid shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"principal only, no action or resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/bob"}}]}`, errPrincipalNotAllowed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Parse(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Validate() = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateSize(t *testing.T) { + tests := []struct { + name string + raw string + wantErr error + }{ + {"valid small document", `{}`, nil}, + {"tab, newline, and carriage return allowed", "a\tb\nc\rd", nil}, + {"empty", "", iamerr.InvalidCharset("policyDocument")}, + {"exactly at max length", strings.Repeat("x", MaxDocumentLength), nil}, + {"one over max length", strings.Repeat("x", MaxDocumentLength+1), iamerr.ValueTooLong("policyDocument", MaxDocumentLength)}, + {"non-latin1 rune rejected", "emoji\U0001F600test", iamerr.InvalidCharset("policyDocument")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate("policyDocument", tt.raw) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateSize() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateSize() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ce6eb55c..4b4baa70 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -57,6 +57,11 @@ func (r *IAMApiRouter) Init() { "DeleteAccessKey": ctrl.DeleteAccessKey, "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, "ListAccessKeys": ctrl.ListAccessKeys, + // User Inline Policy CRUD + "PutUserPolicy": ctrl.PutUserPolicy, + "GetUserPolicy": ctrl.GetUserPolicy, + "DeleteUserPolicy": ctrl.DeleteUserPolicy, + "ListUserPolicies": ctrl.ListUserPolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 3c3b1692..7eef589a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -21,6 +21,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" @@ -113,6 +114,9 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { if !ok { return nil, iamerr.NoSuchEntityUser(username) } + if len(user.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -445,9 +449,163 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return out, nil } +func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInput) 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 + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return nil, iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Users[input.UserName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName string) (*types.PolicyEntry, 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) + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName 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 + } + + user, ok := conf.Users[userName] + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + conf.Users[userName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) cloned.AccessKeys = slices.Clone(user.AccessKeys) + cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 7b8eaed9..d799d711 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -29,6 +29,10 @@ import ( // user may hold at once, matching the AWS IAM quota. const MaxAccessKeysPerUser = 2 +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// all of a single IAM user's inline policy documents combined +const MaxInlinePolicyBytesPerUser = 2048 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -86,6 +90,24 @@ type GetAccessKeyLastUsedOutput struct { Region string } +type PutUserPolicyInput struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserPoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -99,6 +121,11 @@ type Storer interface { DeleteAccessKey(ctx context.Context, username, accessKeyID string) error GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) + + PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error + GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) + DeleteUserPolicy(ctx context.Context, userName, policyName string) error + ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c661db9..48399724 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -233,6 +233,9 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if err != nil { return err } + if len(user.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -553,6 +556,122 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp return out, nil } +func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) { + user, err := s.GetUser(ctx, userName) + if err != nil { + return nil, err + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error { + user, err := s.GetUser(ctx, userName) + if err != nil { + return err + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + // deleteByPath permanently removes a secret and all its versions without // checking for existence first. func (s *VaultStore) deleteByPath(username string) error { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go new file mode 100644 index 00000000..a7149e81 --- /dev/null +++ b/iamapi/types/policy.go @@ -0,0 +1,96 @@ +// 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" +) + +// Policies holds every kind of policy attached to an identity (user, role ...) +// Inline is the only populated field for now +type Policies struct { + Inline []PolicyEntry `json:"inline,omitempty"` +} + +// PolicyEntry is the storage representation of a single inline policy. It +// round-trips through JSON for the internal and Vault storers and is +// never marshaled to XML directly — mirrors AccessKeyEntry. PolicyDocument +// holds the exact bytes submitted by the caller (after validation), not a +// re-serialized form +type PolicyEntry struct { + PolicyName string + PolicyDocument string + CreateDate time.Time + UpdateDate time.Time +} + +type PutUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"` + Result GetUserPolicyResult `xml:"GetUserPolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +// GetUserPolicyResult's PolicyDocument must be RFC 3986 percent-encoded by +// the caller before assignment — see iamutil.EncodePolicyDocument. Real +// IAM returns PolicyDocument URL-encoded; xml.Marshal does not do this +// encoding on its own. +type GetUserPolicyResult struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"` + Result ListUserPoliciesResult `xml:"ListUserPoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListUserPoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserPoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type PolicyNameList struct { + Members []string `xml:"member"` +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index 9083ea2b..f8f4a91f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -106,6 +106,7 @@ type User struct { CreateDate time.Time `xml:"CreateDate"` Tags []Tag `xml:"Tags>member,omitempty"` AccessKeys []AccessKeyEntry `xml:"-"` + Policies Policies `xml:"-"` } type Tag struct { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 0d3750eb..0a74497e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1240,6 +1240,47 @@ func TestIAMListAccessKeys(ts *TestState) { ts.Run(IAMListAccessKeys_pagination) } +func TestIAMPutUserPolicy(ts *TestState) { + ts.Run(IAMPutUserPolicy_missing_user_name) + ts.Run(IAMPutUserPolicy_missing_policy_name) + ts.Run(IAMPutUserPolicy_missing_policy_document) + ts.Run(IAMPutUserPolicy_invalid_policy_name) + ts.Run(IAMPutUserPolicy_long_policy_name) + ts.Run(IAMPutUserPolicy_non_ascii_policy_document) + ts.Run(IAMPutUserPolicy_non_existing_user) + ts.Run(IAMPutUserPolicy_malformed_policy_document) + ts.Run(IAMPutUserPolicy_principal_not_allowed) + ts.Run(IAMPutUserPolicy_limit_exceeded) + ts.Run(IAMPutUserPolicy_success) + ts.Run(IAMPutUserPolicy_overwrite_updates_existing) +} + +func TestIAMGetUserPolicy(ts *TestState) { + ts.Run(IAMGetUserPolicy_missing_user_name) + ts.Run(IAMGetUserPolicy_missing_policy_name) + ts.Run(IAMGetUserPolicy_non_existing_user) + ts.Run(IAMGetUserPolicy_non_existing_policy) + ts.Run(IAMGetUserPolicy_success) +} + +func TestIAMDeleteUserPolicy(ts *TestState) { + ts.Run(IAMDeleteUserPolicy_missing_user_name) + ts.Run(IAMDeleteUserPolicy_missing_policy_name) + ts.Run(IAMDeleteUserPolicy_non_existing_user) + ts.Run(IAMDeleteUserPolicy_non_existing_policy) + ts.Run(IAMDeleteUserPolicy_success) + ts.Run(IAMDeleteUserPolicy_blocks_user_deletion) +} + +func TestIAMListUserPolicies(ts *TestState) { + ts.Run(IAMListUserPolicies_missing_user_name) + ts.Run(IAMListUserPolicies_non_existing_user) + ts.Run(IAMListUserPolicies_invalid_max_items) + ts.Run(IAMListUserPolicies_empty_result) + ts.Run(IAMListUserPolicies_success) + ts.Run(IAMListUserPolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1253,6 +1294,10 @@ func TestIAM(ts *TestState) { TestIAMDeleteAccessKey(ts) TestIAMGetAccessKeyLastUsed(ts) TestIAMListAccessKeys(ts) + TestIAMPutUserPolicy(ts) + TestIAMGetUserPolicy(ts) + TestIAMDeleteUserPolicy(ts) + TestIAMListUserPolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1691,6 +1736,35 @@ func GetIntTests() IntTests { "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, "IAMListAccessKeys_success": IAMListAccessKeys_success, "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, + "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, + "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, + "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, + "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, + "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, + "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, + "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, + "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, + "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, + "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, + "IAMPutUserPolicy_success": IAMPutUserPolicy_success, + "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, + "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, + "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, + "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, + "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, + "IAMGetUserPolicy_success": IAMGetUserPolicy_success, + "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, + "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, + "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, + "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, + "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, + "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, + "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, + "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, + "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, + "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, + "IAMListUserPolicies_success": IAMListUserPolicies_success, + "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, "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_delete_user_policy.go b/tests/integration/iam_delete_user_policy.go new file mode 100644 index 00000000..cf4ac726 --- /dev/null +++ b/tests/integration/iam_delete_user_policy.go @@ -0,0 +1,203 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMDeleteUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMDeleteUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMDeleteUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_success(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteUserPolicy response request id") + } + + _, err = getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUserPolicy(userName, "p")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_blocks_user_deletion(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_blocks_user_deletion" + 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 := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMUserPolicy(client, userName, "p") + deleteUserErr := deleteIAMUser(client, userName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteUserErr + }) +} + +func deleteIAMUserPolicyRaw(client *iam.Client, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteUserPolicy(ctx, input) +} + +func deleteIAMUserPolicy(client *iam.Client, userName, policyName string) error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: &policyName}) + return err +} + +// deleteIAMUserAndPolicies deletes all of the user's inline policies before +// deleting the user, since DeleteUser rejects users with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMUserAndPolicies(client *iam.Client, userName string) error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMUserPolicy(client, userName, policyName); err != nil { + return err + } + } + return deleteIAMUser(client, userName) +} diff --git a/tests/integration/iam_get_user_policy.go b/tests/integration/iam_get_user_policy.go new file mode 100644 index 00000000..b67adb64 --- /dev/null +++ b/tests/integration/iam_get_user_policy.go @@ -0,0 +1,165 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMGetUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMGetUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMGetUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetUserPolicy_success(s *S3Conf) error { + testName := "IAMGetUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetUserPolicy output") + } + if aws.ToString(out.UserName) != userName { + return fmt.Errorf("expected user name %q, instead got %q", userName, aws.ToString(out.UserName)) + } + if aws.ToString(out.PolicyName) != "ReadOnly" { + return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetUserPolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMUserPolicy(client *iam.Client, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetUserPolicy(ctx, input) +} diff --git a/tests/integration/iam_list_user_policies.go b/tests/integration/iam_list_user_policies.go new file mode 100644 index 00000000..c9514ef2 --- /dev/null +++ b/tests/integration/iam_list_user_policies.go @@ -0,0 +1,223 @@ +// 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" + "slices" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListUserPolicies_missing_user_name(s *S3Conf) error { + testName := "IAMListUserPolicies_missing_user_name" + body := []byte("Action=ListUserPolicies&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.MissingValue("userName")) + }) +} + +func IAMListUserPolicies_non_existing_user(s *S3Conf) error { + testName := "IAMListUserPolicies_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListUserPolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListUserPolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_empty_result(s *S3Conf) error { + testName := "IAMListUserPolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if len(out.PolicyNames) != 0 { + return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_success(s *S3Conf) error { + testName := "IAMListUserPolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListUserPolicies response request id") + } + got := slices.Clone(out.PolicyNames) + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_pagination(s *S3Conf) error { + testName := "IAMListUserPolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1)} + var pages []*iam.ListUserPoliciesOutput + for { + out, err := listIAMUserPolicies(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.PolicyNames) != 1 { + return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, page.PolicyNames...) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMUserPolicies(client *iam.Client, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListUserPolicies(ctx, input) +} diff --git a/tests/integration/iam_put_user_policy.go b/tests/integration/iam_put_user_policy.go new file mode 100644 index 00000000..e47e90a3 --- /dev/null +++ b/tests/integration/iam_put_user_policy.go @@ -0,0 +1,376 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +const validIAMPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + +func IAMPutUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + "PolicyDocument": {validIAMPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMPutUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyDocument": {validIAMPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMPutUserPolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMPutUserPolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutUserPolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutUserPolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMPutUserPolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_malformed_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + cases := []struct { + name string + doc string + wantErr iamerr.APIError + }{ + {"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")}, + } + + for _, c := range cases { + if err := func() error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(c.doc), + }) + if err := checkIAMApiErr(err, c.wantErr); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutUserPolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutUserPolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutUserPolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("user", userName, storage.MaxInlinePolicyBytesPerUser)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_success(s *S3Conf) error { + testName := "IAMPutUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutUserPolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutUserPolicy response request id") + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutUserPolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}` + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != updated { + return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMUserPolicy(client *iam.Client, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutUserPolicy(ctx, input) +} From cbcc656f53fa9cdbf46e99aae3f1790e07a6b2a4 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 14 Jul 2026 22:24:01 +0400 Subject: [PATCH 04/10] feat: add IAM Role CRUD Adds `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole`, and `UpdateAssumeRolePolicy` to the standalone IAM service, following the same controller/storage patterns established for users. Both the internal filesystem/S3-backed store and the Vault-backed store implement the new `Storer` methods, with role-specific indexing and lookup helpers mirroring the existing user ones. Role creation requires a trust policy, passed as `AssumeRolePolicyDocument`. A trust policy is a distinct kind of IAM policy document that governs who (or what) is allowed to assume a role, rather than what actions the role itself is permitted to perform. Its grammar is effectively the inverse of an identity policy: `Principal` is required, `Action`/`NotAction` values must carry the `sts:` prefix, and `Resource`/`NotResource` are forbidden. This is implemented in `iamapi/policy/trust.go` as a new validation path alongside the existing identity-policy validation, and is reused by `UpdateAssumeRolePolicy` when replacing a role's trust policy. Also fixes user name uniqueness enforcement to be case-insensitive, matching AWS IAM behavior, and applies the same case-insensitive handling to role names. The internal store now maintains lowercase name indexes for both users and roles, and the Vault store resolves the canonical stored key via a case-insensitive list-and-compare fallback since Vault's KV paths are case-sensitive. --- iamapi/controller.go | 196 ++++++++ iamapi/controller_test.go | 473 ++++++++++++++++++ iamapi/iamerr/errors.go | 30 +- iamapi/internal/iamutil/user.go | 86 ++++ iamapi/policy/document.go | 4 + iamapi/policy/trust.go | 212 ++++++++ iamapi/policy/trust_test.go | 92 ++++ iamapi/router.go | 6 + iamapi/storage/internal.go | 271 +++++++++- iamapi/storage/storer.go | 24 + iamapi/storage/storer_test.go | 163 ++++++ iamapi/storage/vault.go | 326 +++++++++++- iamapi/types/role.go | 113 +++++ runiamtests.sh | 2 +- tests/integration/group-tests.go | 116 +++++ tests/integration/iam_create_role.go | 433 ++++++++++++++++ tests/integration/iam_create_user.go | 21 + tests/integration/iam_delete_role.go | 95 ++++ tests/integration/iam_get_role.go | 122 +++++ tests/integration/iam_list_roles.go | 375 ++++++++++++++ .../iam_update_assume_role_policy.go | 253 ++++++++++ tests/integration/utils.go | 54 ++ 22 files changed, 3434 insertions(+), 33 deletions(-) create mode 100644 iamapi/policy/trust.go create mode 100644 iamapi/policy/trust_test.go create mode 100644 iamapi/types/role.go create mode 100644 tests/integration/iam_create_role.go create mode 100644 tests/integration/iam_delete_role.go create mode 100644 tests/integration/iam_get_role.go create mode 100644 tests/integration/iam_list_roles.go create mode 100644 tests/integration/iam_update_assume_role_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index ef4b5086..f224d24b 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -522,3 +522,199 @@ func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxUserNameLen, iamerr.MissingValue("roleName")) + if 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 + } + + assumeRolePolicyDocument, ok := iamutil.RequestParam(ctx, "AssumeRolePolicyDocument") + if !ok || assumeRolePolicyDocument == "" { + debuglogger.Logf("missing required CreateRole parameter: AssumeRolePolicyDocument") + return nil, iamerr.MissingValue("assumeRolePolicyDocument") + } + if err := policy.Validate("assumeRolePolicyDocument", assumeRolePolicyDocument); err != nil { + return nil, err + } + if err := policy.ParseTrust(assumeRolePolicyDocument); err != nil { + return nil, err + } + if len(assumeRolePolicyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + description, _ := iamutil.RequestParam(ctx, "Description") + if err := iamutil.ValidateDescription("description", description); err != nil { + return nil, err + } + + maxSessionDuration, err := iamutil.ParseMaxSessionDuration(ctx) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + for range 3 { + roleID, err := iamutil.GenerateRoleID() + if err != nil { + return nil, err + } + + role := types.Role{ + Path: path, + RoleName: roleName, + RoleID: roleID, + Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName), + CreateDate: time.Now().UTC().Truncate(time.Second), + AssumeRolePolicyDocument: assumeRolePolicyDocument, + Description: description, + MaxSessionDuration: maxSessionDuration, + Tags: tags, + } + + stored, err := c.store.CreateRole(ctx.Context(), role) + if errors.Is(err, storage.ErrRoleIDAlreadyExists) { + debuglogger.Logf("IAM role ID collision while creating role %q: %v", roleName, err) + continue + } + if err != nil { + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err + } + + stored.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(stored.AssumeRolePolicyDocument) + + return &Response{Data: &types.CreateRoleResponse{ + Result: types.CreateRoleResult{Role: stored}, + }}, nil + } + + err = fmt.Errorf("generate IAM role id: exhausted collision retries") + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err +} + +func (c IAMApiController) GetRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + role, err := c.store.GetRole(ctx.Context(), roleName) + if err != nil { + debuglogger.Logf("failed to get IAM role %q: %v", roleName, err) + return nil, err + } + + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + + return &Response{Data: &types.GetRoleResponse{ + Result: types.GetRoleResult{Role: role}, + }}, nil +} + +func (c IAMApiController) ListRoles(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, err := iamutil.ParseMaxItems(ctx, "ListRoles") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRoles(ctx.Context(), storage.ListRolesInput{ + PathPrefix: pathPrefix, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM roles: %v", err) + return nil, err + } + + roles := make([]types.Role, len(out.Roles)) + for i, role := range out.Roles { + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + roles[i] = role + } + + return &Response{Data: &types.ListRolesResponse{ + Result: types.ListRolesResult{ + Roles: types.Roles{Members: roles}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + +func (c IAMApiController) DeleteRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to delete IAM role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRoleResponse{}}, nil +} + +func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required UpdateAssumeRolePolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + // Confirm the role exists before inspecting policy document content + if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to get IAM role %q for UpdateAssumeRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.ParseTrust(policyDocument); err != nil { + return nil, err + } + if len(policyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + if _, err := c.store.UpdateAssumeRolePolicy(ctx.Context(), storage.UpdateAssumeRolePolicyInput{ + RoleName: roleName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to update IAM assume role policy for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 58cf4db8..dd975302 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -30,6 +30,7 @@ import ( ) var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`) +var roleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) func TestIAMApiControllerUserLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -828,6 +829,478 @@ func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") } +const validTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +func TestIAMApiControllerRoleLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"/engineering/"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"a test role"}, + "MaxSessionDuration": {"7200"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateRoleResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateRoleResponse" { + t.Fatalf("CreateRole XMLName = %#v", createOut.XMLName) + } + role := createOut.Result.Role + if role.Path != "/engineering/" || role.RoleName != "my-role" { + t.Fatalf("created role = %#v, want path/name", role) + } + if !roleIDPattern.MatchString(role.RoleID) { + t.Fatalf("RoleId = %q, want AWS IAM role id form", role.RoleID) + } + if role.Arn != "arn:aws:iam::000000000000:role/engineering/my-role" { + t.Fatalf("Arn = %q", role.Arn) + } + if role.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + if role.Description != "a test role" { + t.Fatalf("Description = %q", role.Description) + } + if role.MaxSessionDuration != 7200 { + t.Fatalf("MaxSessionDuration = %d, want 7200", role.MaxSessionDuration) + } + wantEncodedPolicy := iamutil.EncodePolicyDocument(validTrustPolicy) + if role.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("AssumeRolePolicyDocument = %q, want %q", role.AssumeRolePolicyDocument, wantEncodedPolicy) + } + if role.RoleLastUsed == nil { + t.Fatal("CreateRole RoleLastUsed = nil, want non-nil empty element") + } + if len(role.Tags) != 1 || role.Tags[0].Key != "env" || role.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", role.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateRole missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"MY-ROLE"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", "Role with name MY-ROLE already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRole status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, get), &getOut) + gotRole := getOut.Result.Role + if gotRole.RoleID != role.RoleID || !gotRole.CreateDate.Equal(role.CreateDate) { + t.Fatalf("GetRole identity = %#v, want RoleId/CreateDate preserved from %#v", gotRole, role) + } + if gotRole.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + if gotRole.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("GetRole AssumeRolePolicyDocument = %q, want %q", gotRole.AssumeRolePolicyDocument, wantEncodedPolicy) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRoles"}, + "PathPrefix": {"/engineering/"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRoles status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.Roles.Members) != 1 || listOut.Result.Roles.Members[0].RoleName != "my-role" { + t.Fatalf("ListRoles = %#v, want my-role", listOut.Result.Roles.Members) + } + if listOut.Result.Roles.Members[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", listOut.Result.Roles.Members[0].RoleLastUsed) + } + if listOut.Result.Roles.Members[0].AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("ListRoles AssumeRolePolicyDocument = %q, want %q", listOut.Result.Roles.Members[0].AssumeRolePolicyDocument, wantEncodedPolicy) + } + + const updatedTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + update := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {updatedTrustPolicy}, + }) + if update.StatusCode != http.StatusOK { + t.Fatalf("UpdateAssumeRolePolicy status = %d, body=%s", update.StatusCode, readBody(t, update)) + } + var updateOut iamtypes.UpdateAssumeRolePolicyResponse + unmarshalXML(t, readBody(t, update), &updateOut) + if updateOut.XMLName.Local != "UpdateAssumeRolePolicyResponse" || updateOut.ResponseMetadata.RequestID == "" { + t.Fatalf("UpdateAssumeRolePolicy output = %#v", updateOut) + } + + oversizedTrustPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + updateOversized := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {oversizedTrustPolicy}, + }) + requireIAMError(t, updateOversized, http.StatusConflict, "Sender", "LimitExceeded", "Cannot exceed quota for ACLSizePerRole: 2048") + + getAfterUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + var getAfterUpdateOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, getAfterUpdate), &getAfterUpdateOut) + wantUpdatedEncoded := iamutil.EncodePolicyDocument(updatedTrustPolicy) + if getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument != wantUpdatedEncoded { + t.Fatalf("GetRole after update AssumeRolePolicyDocument = %q, want %q", getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument, wantUpdatedEncoded) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"my-role"}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + var deleteOut iamtypes.DeleteRoleResponse + unmarshalXML(t, readBody(t, deleteResp), &deleteOut) + if deleteOut.XMLName.Local != "DeleteRoleResponse" || deleteOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRole output = %#v", deleteOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role with name my-role cannot be found.") +} + +func TestIAMApiControllerCreateRoleValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing role name", + params: url.Values{ + "Action": {"CreateRole"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"bad/name"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for roleName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "long role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {strings.Repeat("a", 65)}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must have length less than or equal to 64", + }, + { + name: "invalid path", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"bad"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + 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: "missing assume role policy document", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid json policy", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {"{invalid"}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "This policy contains invalid Json", + }, + { + name: "policy statement empty", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Could not parse the policy: Statement is empty!", + }, + { + name: "policy missing principal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal", + }, + { + name: "policy principal empty object", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal cannot be empty!", + }, + { + name: "policy action not sts prefixed", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy may only specify STS AssumeRole actions.", + }, + { + name: "policy has resource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Has prohibited field Resource", + }, + { + name: "policy has notresource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy must not contain resources.", + }, + { + name: "policy allow with notprincipal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Allow with NotPrincipal is not allowed.", + }, + { + name: "policy too large", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {strings.Repeat("x", 131073)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072", + }, + { + name: "description invalid charset", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"emoji\U0001F600test"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'description' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", + }, + { + name: "trust policy exceeds ACLSizePerRole quota", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Cannot exceed quota for ACLSizePerRole: 2048", + }, + { + name: "max session duration not a number", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"not-a-number"}, + }, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "max session duration too low", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"3599"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600", + }, + { + name: "max session duration too high", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"43201"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200", + }, + { + name: "duplicate tag key", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "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.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMActionPost(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "get missing role name", + params: url.Values{ + "Action": {"GetRole"}, + }, + status: http.StatusBadRequest, + code: "MissingParameter", + message: "The request must contain the parameter RoleName.", + }, + { + name: "get missing role", + params: url.Values{ + "Action": {"GetRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "delete missing role", + params: url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing role", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + "PolicyDocument": {validTrustPolicy}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing document", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + } + + 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 newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 5c55c6dd..4c919a0e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -113,7 +113,7 @@ func (e Error) XMLBody(requestID string) []byte { type errorXML struct { Type ErrorType Code string - Message string + Message string `xml:",omitempty"` } var errorCodeResponse = map[ErrorCode]Error{ @@ -345,10 +345,22 @@ func NoSuchEntityAccessKey(accessKeyID string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The Access Key with id %s cannot be found", accessKeyID), http.StatusNotFound) } +func EntityAlreadyExistsRole(roleName string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Role with name %s already exists.", roleName), http.StatusConflict) +} + +func NoSuchEntityRole(roleName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role with name %s cannot be found.", roleName), http.StatusNotFound) +} + func AccessKeysLimitExceeded(maxKeys int) Error { return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for AccessKeysPerUser: %d", maxKeys), http.StatusConflict) } +func TrustPolicySizeLimitExceeded(maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ACLSizePerRole: %d", maxBytes), http.StatusConflict) +} + func ValidationError(message string) Error { return newSenderError("ValidationError", message, http.StatusBadRequest) } @@ -417,6 +429,22 @@ func InvalidCharset(field string) Error { return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) } +func InvalidDescriptionCharset(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", field)) +} + +func MaxSessionDurationTooLow() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600") +} + +func MaxSessionDurationTooHigh() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200") +} + +func MalformedInput() Error { + return newSenderError("MalformedInput", "", http.StatusBadRequest) +} + func MalformedPolicyDocument(message string) Error { return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) } diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 68bcb667..24a91189 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -41,6 +41,15 @@ const ( userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" maxTagKeyLen = 128 maxTagValLen = 256 + + roleIDPrefix = "AROA" + roleIDRandomLen = 17 + + MaxRoleDescriptionLen = 1000 + + DefaultMaxSessionDuration = 3600 + MinMaxSessionDuration = 3600 + MaxMaxSessionDuration = 43200 ) var ( @@ -82,6 +91,68 @@ func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) return userName, nil } +// GetRoleName resolves the RoleName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +func GetRoleName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + roleName, ok := RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + debuglogger.Logf("missing required %s parameter: RoleName", operation) + return "", missingErr + } + if err := ValidateName("roleName", roleName, maxLen); err != nil { + return "", err + } + + return roleName, nil +} + +// ParseMaxSessionDuration reads the MaxSessionDuration request parameter, +// defaulting to DefaultMaxSessionDuration when absent, and validates it +// falls within [MinMaxSessionDuration, MaxMaxSessionDuration]. +func ParseMaxSessionDuration(ctx fiber.Ctx) (int32, error) { + raw, ok := RequestParam(ctx, "MaxSessionDuration") + if !ok || raw == "" { + return DefaultMaxSessionDuration, nil + } + + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + debuglogger.Logf("malformed MaxSessionDuration value %q", raw) + return 0, iamerr.MalformedInput() + } + if parsed < MinMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooLow() + } + if parsed > MaxMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooHigh() + } + + return int32(parsed), nil +} + +// ValidateDescription checks that the IAM role "Description" fits +// within MaxRoleDescriptionLen and uses the allowed charset — printable +// Latin-1 (excluding 0x7F-0xA0) plus tab/LF/CR +func ValidateDescription(field, desc string) error { + if len(desc) > MaxRoleDescriptionLen { + debuglogger.Logf("IAM role description exceeds maximum length: field=%s length=%d max=%d", field, len(desc), MaxRoleDescriptionLen) + return iamerr.ValueTooLong(field, MaxRoleDescriptionLen) + } + for _, r := range desc { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || (r > 0x7E && r < 0xA1) || r > 0xFF { + debuglogger.Logf("invalid IAM role description charset: field=%s", field) + return iamerr.InvalidDescriptionCharset(field) + } + } + return nil +} + // ParseMaxItems reads the MaxItems request parameter, defaulting to // DefaultMaxItems when absent. operation is included in the debug log on // parse failure (e.g. "ListUsers", "ListAccessKeys"). @@ -198,6 +269,21 @@ func GenerateUserID() (string, error) { return id, nil } +// BuildRoleArn constructs the ARN for an IAM role. +func BuildRoleArn(accountID, path, roleName string) string { + return fmt.Sprintf("arn:aws:iam::%s:role%s%s", accountID, path, roleName) +} + +// GenerateRoleID returns a new cryptographically random IAM role ID in the AROA… format. +func GenerateRoleID() (string, error) { + id, err := generateAWSID(roleIDPrefix, roleIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM role ID: %v", err) + return "", err + } + return id, nil +} + // generateAWSID builds an AWS-style unique identifier: a fixed prefix // followed by randomLen characters drawn from userIDAlphabet. func generateAWSID(prefix string, randomLen int) (string, error) { diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go index 39a7bc04..50490de2 100644 --- a/iamapi/policy/document.go +++ b/iamapi/policy/document.go @@ -41,6 +41,10 @@ type Statement struct { NotResource StringOrSlice Principal json.RawMessage NotPrincipal json.RawMessage + // Condition is never structurally validated (neither the identity- nor + // trust-policy path models its grammar) — it is only checked for + // presence, by the trust-policy Cognito-provider rule. + Condition json.RawMessage } // UnmarshalJSON accepts Statement as either a single JSON object or an diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go new file mode 100644 index 00000000..c86380f2 --- /dev/null +++ b/iamapi/policy/trust.go @@ -0,0 +1,212 @@ +// 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 policy + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// trustPrincipalKeys are the only keys IAM accepts inside a trust policy +// statement's Principal object. CanonicalUser is deliberately not accepted +// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical +// user id which is the legacy s3 user identifier and is not planned to support +var trustPrincipalKeys = map[string]bool{ + "AWS": true, + "Service": true, + "Federated": true, +} + +const cognitoFederatedProvider = "cognito-identity.amazonaws.com" + +// validServicePrincipals are the only Service principal values the gateway +// recognizes. Real AWS validates Service against its live catalog of +// ~300+ service principals; the gateway only exposes S3, STS, and IAM +// APIs, so those are the only services that could plausibly ever assume a +// role here. +var validServicePrincipals = map[string]bool{ + "s3.amazonaws.com": true, + "sts.amazonaws.com": true, + "iam.amazonaws.com": true, +} + +// MaxTrustPolicyBytes is IAM's ACLSizePerRole quota: a role has exactly one +// trust policy, so unlike inline identity policies (which sum across all of +// a user's/role's named policies) this is a plain length check against the +// single AssumeRolePolicyDocument/PolicyDocument value. +const MaxTrustPolicyBytes = 2048 + +var ( + errTrustInvalidJSON = iamerr.MalformedPolicyDocument("This policy contains invalid Json") + errTrustInvalidVersion = iamerr.MalformedPolicyDocument("The policy must contain a valid version string") + errTrustEmptyStatement = iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!") + errTrustDuplicateSid = iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique") + errTrustMissingEffect = iamerr.MalformedPolicyDocument("Missing required field Effect") + errTrustMissingPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal") + errTrustEmptyPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!") + errTrustPrincipalNotObject = iamerr.MalformedPolicyDocument("Principal must be a JSON object.") + errTrustAllowNotPrincipal = iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.") + errTrustNotPrincipalForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.") + errTrustMissingAction = iamerr.MalformedPolicyDocument("Missing required field Action") + errTrustNonSTSAction = iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.") + errTrustResourceForbidden = iamerr.MalformedPolicyDocument("Has prohibited field Resource") + errTrustNotResourceForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.") + errTrustCognitoConditionRequired = iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider") + errTrustSyntax = iamerr.MalformedPolicyDocument("Syntax error in policy.") +) + +// ParseTrust parses raw as an IAM role trust-policy document (the value of +// AssumeRolePolicyDocument / UpdateAssumeRolePolicy's PolicyDocument) and +// checks it against trust-policy grammar: Principal is required (the +// opposite of an identity policy), Action/NotAction values must carry the +// "sts:" prefix, and Resource/NotResource are forbidden. +func ParseTrust(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errTrustInvalidJSON + } + return doc.ValidateTrust() +} + +// ValidateTrust checks d against IAM's trust-policy document grammar: a +// valid Version if present, a non-empty Statement (single object or +// array), document-wide unique Sids, and per statement, the rules enforced +// by Statement.ValidateTrust. +func (d Document) ValidateTrust() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errTrustInvalidVersion + } + if len(d.Statement) == 0 { + return errTrustEmptyStatement + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.ValidateTrust(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errTrustDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// ValidateTrust checks s against IAM trust-policy statement grammar: a +// valid Effect, a required Principal (never NotPrincipal), an Action or +// NotAction with only "sts:"-prefixed values, and no Resource/NotResource. +// Condition is not modeled or validated(not supported at the moment) +func (s Statement) ValidateTrust() error { + switch s.Effect { + case "Allow", "Deny": + case "": + return errTrustMissingEffect + default: + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid effect: %s", s.Effect)) + } + + if len(s.NotPrincipal) > 0 { + if s.Effect == "Allow" { + return errTrustAllowNotPrincipal + } + return errTrustNotPrincipalForbidden + } + if err := s.validateTrustPrincipal(); err != nil { + return err + } + + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errTrustMissingAction + } + for _, action := range s.Action { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + for _, action := range s.NotAction { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + + if len(s.Resource) > 0 { + return errTrustResourceForbidden + } + if len(s.NotResource) > 0 { + return errTrustNotResourceForbidden + } + + return nil +} + +// validateTrustPrincipal checks s.Principal against trust-policy grammar: +// required, a JSON object (not a bare string or array), non-empty, with +// only AWS/Service/Federated keys, plus the Cognito-specific Condition +// requirement. Real AWS additionally validates that AWS/Service values +// resolve to real accounts/services against its live catalog; the gateway +// has no such catalog for AWS account/ARN values and validates those shape +// only. Service values are the exception — they're checked against +// validServicePrincipals, since the gateway only exposes S3, STS, and IAM +// APIs and so only those services could ever assume a role here. +func (s Statement) validateTrustPrincipal() error { + raw := s.Principal + if len(raw) == 0 { + return errTrustMissingPrincipal + } + + var principal map[string]StringOrSlice + if err := json.Unmarshal(raw, &principal); err != nil { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return errTrustPrincipalNotObject + } + return errTrustSyntax + } + + if len(principal) == 0 { + return errTrustEmptyPrincipal + } + + requiresCondition := false + for key, values := range principal { + if !trustPrincipalKeys[key] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key)) + } + if key == "Service" { + for _, v := range values { + if !validServicePrincipals[v] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q:%q", strings.ToUpper(key), v)) + } + } + } + if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) { + requiresCondition = true + } + } + + if requiresCondition && len(s.Condition) == 0 { + return errTrustCognitoConditionRequired + } + + return nil +} diff --git a/iamapi/policy/trust_test.go b/iamapi/policy/trust_test.go new file mode 100644 index 00000000..ecc51cf6 --- /dev/null +++ b/iamapi/policy/trust_test.go @@ -0,0 +1,92 @@ +// 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 policy + +import ( + "errors" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account, except +// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md). +// The "ec2 service (unsupported)" case is one such deliberate deviation: +// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, +// STS, and IAM APIs, so it restricts Service principals to those three. +func TestParseTrust(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means ParseTrust must succeed + }{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errTrustInvalidJSON}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustInvalidVersion}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errTrustEmptyStatement}, + {"missing statement", `{"Version":"2012-10-17"}`, errTrustEmptyStatement}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustMissingEffect}, + + {"missing principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, errTrustMissingPrincipal}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, errTrustEmptyPrincipal}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, errTrustPrincipalNotObject}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, errTrustSyntax}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (unsupported)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustAllowNotPrincipal}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction}, + {"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, errTrustResourceForbidden}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, errTrustNotResourceForbidden}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustDuplicateSid}, + + {"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ParseTrust(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ParseTrust() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ParseTrust() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index 4b4baa70..4e6444e9 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -62,6 +62,12 @@ func (r *IAMApiRouter) Init() { "GetUserPolicy": ctrl.GetUserPolicy, "DeleteUserPolicy": ctrl.DeleteUserPolicy, "ListUserPolicies": ctrl.ListUserPolicies, + // Role CRUD + "CreateRole": ctrl.CreateRole, + "GetRole": ctrl.GetRole, + "ListRoles": ctrl.ListRoles, + "DeleteRole": ctrl.DeleteRole, + "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 7eef589a..81a76a61 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -54,12 +54,24 @@ type iamConfig struct { // AccessKeyIndex maps an access key id to the username that owns it, // so GetAccessKeyLastUsed can resolve a key without scanning every user. AccessKeyIndex map[string]string `json:"accessKeyIndex"` + // UserNameIndex maps a lowercased user name to the canonical (as-created) + // stored user name, so lookups can enforce AWS's case-insensitive + // uniqueness while still preserving the original casing in conf.Users's + // key and the stored User.UserName. + UserNameIndex map[string]string `json:"userNameIndex"` + + Roles map[string]types.Role `json:"roles"` + // RoleNameIndex is UserNameIndex's counterpart for roles. + RoleNameIndex map[string]string `json:"roleNameIndex"` } func defaultIAMConfig() iamConfig { return iamConfig{ Users: map[string]types.User{}, AccessKeyIndex: map[string]string{}, + UserNameIndex: map[string]string{}, + Roles: map[string]types.Role{}, + RoleNameIndex: map[string]string{}, } } @@ -70,6 +82,49 @@ func normalizeIAMConfig(conf *iamConfig) { if conf.AccessKeyIndex == nil { conf.AccessKeyIndex = make(map[string]string) } + if conf.UserNameIndex == nil { + conf.UserNameIndex = make(map[string]string) + } + for name := range conf.Users { + key := strings.ToLower(name) + if _, ok := conf.UserNameIndex[key]; !ok { + conf.UserNameIndex[key] = name + } + } + + if conf.Roles == nil { + conf.Roles = make(map[string]types.Role) + } + if conf.RoleNameIndex == nil { + conf.RoleNameIndex = make(map[string]string) + } + for name := range conf.Roles { + key := strings.ToLower(name) + if _, ok := conf.RoleNameIndex[key]; !ok { + conf.RoleNameIndex[key] = name + } + } +} + +// lookupUser resolves name to the canonical stored user name and entry, +// case-insensitively, via conf.UserNameIndex. +func lookupUser(conf iamConfig, name string) (string, types.User, bool) { + canonical, ok := conf.UserNameIndex[strings.ToLower(name)] + if !ok { + return "", types.User{}, false + } + user, ok := conf.Users[canonical] + return canonical, user, ok +} + +// lookupRole is lookupUser's counterpart for roles. +func lookupRole(conf iamConfig, name string) (string, types.Role, bool) { + canonical, ok := conf.RoleNameIndex[strings.ToLower(name)] + if !ok { + return "", types.Role{}, false + } + role, ok := conf.Roles[canonical] + return canonical, role, ok } func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { @@ -82,7 +137,8 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U return nil, err } - if _, ok := conf.Users[user.UserName]; ok { + key := strings.ToLower(user.UserName) + if _, ok := conf.UserNameIndex[key]; ok { return nil, iamerr.EntityAlreadyExistsUser(user.UserName) } for _, existing := range conf.Users { @@ -92,6 +148,7 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U } conf.Users[user.UserName] = user + conf.UserNameIndex[key] = user.UserName return json.Marshal(conf) }); err != nil { return nil, unwrapAPIError(err) @@ -110,7 +167,7 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -121,7 +178,8 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - delete(conf.Users, username) + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) return json.Marshal(conf) }) return unwrapAPIError(err) @@ -136,7 +194,7 @@ func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User return nil, err } - user, ok := conf.Users[username] + _, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -204,7 +262,7 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -213,8 +271,8 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { - if _, ok := conf.Users[finalName]; ok { + if !strings.EqualFold(finalName, canonical) { + if _, ok := conf.UserNameIndex[strings.ToLower(finalName)]; ok { return nil, iamerr.EntityAlreadyExistsUser(finalName) } } @@ -229,13 +287,15 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t user.Arn = input.NewArn } - if user.UserName != input.UserName { - delete(conf.Users, input.UserName) + if user.UserName != canonical { + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) for _, key := range user.AccessKeys { conf.AccessKeyIndex[key.AccessKeyId] = user.UserName } } conf.Users[user.UserName] = user + conf.UserNameIndex[strings.ToLower(user.UserName)] = user.UserName updated = user return json.Marshal(conf) @@ -257,7 +317,7 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -274,11 +334,11 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey Status: input.Status, CreateDate: input.CreateDate, }) - conf.Users[input.UserName] = user - conf.AccessKeyIndex[input.AccessKeyID] = input.UserName + conf.Users[canonical] = user + conf.AccessKeyIndex[input.AccessKeyID] = canonical created = types.AccessKey{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: input.AccessKeyID, Status: input.Status, SecretAccessKey: input.SecretAccessKey, @@ -303,7 +363,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -320,7 +380,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, iamerr.NoSuchEntityAccessKey(input.AccessKeyID) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -336,7 +396,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -353,7 +413,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID } user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) - conf.Users[username] = user + conf.Users[canonical] = user delete(conf.AccessKeyIndex, accessKeyID) return json.Marshal(conf) @@ -402,7 +462,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -410,7 +470,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) for _, key := range user.AccessKeys { keys = append(keys, types.AccessKeyMetadata{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: key.AccessKeyId, Status: key.Status, CreateDate: key.CreateDate, @@ -459,7 +519,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -490,7 +550,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu }) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -505,7 +565,7 @@ func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName st return nil, err } - user, ok := conf.Users[userName] + _, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -530,7 +590,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName return nil, err } - user, ok := conf.Users[userName] + canonical, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -547,7 +607,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName } user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) - conf.Users[userName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -562,7 +622,7 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return nil, err } - user, ok := conf.Users[input.UserName] + _, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -602,6 +662,160 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return out, nil } +func (s *InternalStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + role.EnsureRoleLastUsed() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + key := strings.ToLower(role.RoleName) + if _, ok := conf.RoleNameIndex[key]; ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + for _, existing := range conf.Roles { + if existing.RoleID == role.RoleID { + return nil, ErrRoleIDAlreadyExists + } + } + + conf.Roles[role.RoleName] = role + conf.RoleNameIndex[key] = role.RoleName + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) ListRoles(_ context.Context, input ListRolesInput) (*ListRolesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + roles := make([]types.Role, 0, len(conf.Roles)) + for _, role := range conf.Roles { + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though it's persisted — + // matches the documented list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, role) + } + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *InternalStore) DeleteRole(_ context.Context, roleName 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 + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + if len(role.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + + delete(conf.Roles, canonical) + delete(conf.RoleNameIndex, strings.ToLower(canonical)) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + var updated types.Role + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + role.AssumeRolePolicyDocument = input.PolicyDocument + conf.Roles[canonical] = role + updated = role + + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(updated), nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) @@ -609,3 +823,10 @@ func cloneUser(user types.User) *types.User { cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } + +func cloneRole(role types.Role) *types.Role { + cloned := role + cloned.Tags = slices.Clone(role.Tags) + cloned.Policies.Inline = slices.Clone(role.Policies.Inline) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index d799d711..f862d00f 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -36,6 +36,7 @@ const MaxInlinePolicyBytesPerUser = 2048 var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") + ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") ) type ListUsersInput struct { @@ -108,6 +109,23 @@ type ListUserPoliciesOutput struct { Marker string } +type ListRolesInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListRolesOutput struct { + Roles []types.Role + IsTruncated bool + Marker string +} + +type UpdateAssumeRolePolicyInput struct { + RoleName string + PolicyDocument string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -126,6 +144,12 @@ type Storer interface { GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) DeleteUserPolicy(ctx context.Context, userName, policyName string) error ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) + + CreateRole(ctx context.Context, role types.Role) (*types.Role, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) + DeleteRole(ctx context.Context, roleName string) error + UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index be0ea36d..e54104a0 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -221,3 +221,166 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDA11111111111111111"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDA22222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { + t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + + got, err := store.GetUser(ctx, "ALICE") + if err != nil { + t.Fatalf("GetUser case-insensitive lookup: %v", err) + } + if got.UserName != "alice" { + t.Fatalf("GetUser case-insensitive lookup = %#v, want canonical casing preserved", got) + } + + if err := store.DeleteUser(ctx, "ALICE"); err != nil { + t.Fatalf("DeleteUser case-insensitive lookup: %v", err) + } + if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) { + t.Fatalf("GetUser after case-insensitive delete err = %v, want NoSuchEntity", err) + } +} + +func TestInternalStoreRoleCRUDAndPagination(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, 7, 11, 18, 0, 0, 0, time.UTC) + roles := []types.Role{ + { + Path: "/engineering/", + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + Arn: "arn:aws:iam::000000000000:role/engineering/alice-role", + CreateDate: created, + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + Tags: []types.Tag{ + {Key: "env", Value: "test"}, + }, + }, + { + Path: "/engineering/platform/", + RoleName: "bob-role", + RoleID: "AROA33333333333333333", + Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role", + CreateDate: created.Add(time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + { + Path: "/ops/", + RoleName: "carol-role", + RoleID: "AROA44444444444444444", + Arn: "arn:aws:iam::000000000000:role/ops/carol-role", + CreateDate: created.Add(2 * time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + } + for _, role := range roles { + created, err := store.CreateRole(ctx, role) + if err != nil { + t.Fatalf("CreateRole(%s): %v", role.RoleName, err) + } + if created.RoleLastUsed == nil { + t.Fatalf("CreateRole(%s) RoleLastUsed = nil, want non-nil empty element", role.RoleName) + } + } + + if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) { + t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err) + } + if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROA55555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { + t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + duplicateID := roles[2] + duplicateID.RoleName = "dave-role" + if _, err := store.CreateRole(ctx, duplicateID); !errors.Is(err, ErrRoleIDAlreadyExists) { + t.Fatalf("CreateRole duplicate id err = %v, want ErrRoleIDAlreadyExists", err) + } + + got, err := store.GetRole(ctx, "ALICE-ROLE") + if err != nil { + t.Fatalf("GetRole: %v", err) + } + if got.RoleName != "alice-role" || got.RoleID != roles[0].RoleID { + t.Fatalf("GetRole = %#v, want alice-role with stable id and preserved casing", got) + } + if !reflect.DeepEqual(got.Tags, roles[0].Tags) { + t.Fatalf("GetRole tags = %#v, want %#v", got.Tags, roles[0].Tags) + } + if got.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + + page1, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRoles page1: %v", err) + } + if len(page1.Roles) != 1 || page1.Roles[0].RoleName != "alice-role" || !page1.IsTruncated || page1.Marker != "alice-role" { + t.Fatalf("page1 = %#v, want truncated alice-role page", page1) + } + if page1.Roles[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", page1.Roles[0].RoleLastUsed) + } + + page2, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRoles page2: %v", err) + } + if len(page2.Roles) != 1 || page2.Roles[0].RoleName != "bob-role" || page2.IsTruncated { + t.Fatalf("page2 = %#v, want final bob-role page", page2) + } + + updatedRole, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{ + RoleName: "alice-role", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, + }) + if err != nil { + t.Fatalf("UpdateAssumeRolePolicy: %v", err) + } + if updatedRole.AssumeRolePolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` { + t.Fatalf("UpdateAssumeRolePolicy result = %#v", updatedRole) + } + if updatedRole.RoleID != roles[0].RoleID { + t.Fatalf("UpdateAssumeRolePolicy identity changed: %#v", updatedRole) + } + if _, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{RoleName: "missing-role", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("UpdateAssumeRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + reopenedRole, err := reopened.GetRole(ctx, "alice-role") + if err != nil { + t.Fatalf("GetRole after reopen: %v", err) + } + if reopenedRole.AssumeRolePolicyDocument != updatedRole.AssumeRolePolicyDocument { + t.Fatalf("reopened AssumeRolePolicyDocument = %q, want %q", reopenedRole.AssumeRolePolicyDocument, updatedRole.AssumeRolePolicyDocument) + } + + if err := reopened.DeleteRole(ctx, "carol-role"); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + if err := reopened.DeleteRole(ctx, "carol-role"); !errors.Is(err, iamerr.NoSuchEntityRole("carol-role")) { + t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 48399724..d54c97a2 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -191,7 +191,45 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return nil } +// findUserKey resolves name to the exact stored KV path segment (the +// original UserName casing used at creation), case-insensitively, by +// listing the users under secretStoragePath and comparing with EqualFold. +// AWS enforces case-insensitive UserName uniqueness but Vault's KV paths +// are plain case-sensitive strings, so a list+compare fallback is needed — +// KV has no native case-insensitive lookup. ok is false both when nothing +// matches and (harmlessly) when the prefix has no children at all. +func (s *VaultStore) findUserKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { + if _, ok, err := s.findUserKey(user.UserName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsUser(user.UserName) + } + userMap, err := userToVaultMap(user) if err != nil { return nil, fmt.Errorf("serialize user: %w", err) @@ -239,11 +277,19 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - return s.deleteByPath(username) + return s.deleteByPath(user.UserName) } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - path := s.secretStoragePath + "/" + username + canonical, ok, err := s.findUserKey(username) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityUser(username) + } + + path := s.secretStoragePath + "/" + canonical resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { @@ -261,7 +307,7 @@ func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, e } } - user, err := parseVaultUser(resp.Data.Data, username) + user, err := parseVaultUser(resp.Data.Data, canonical) if err != nil { return nil, err } @@ -340,13 +386,14 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty if err != nil { return nil, err } + originalName := user.UserName finalName := user.UserName if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { + if !strings.EqualFold(finalName, originalName) { existing, err := s.GetUser(ctx, finalName) if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) { return nil, err @@ -366,12 +413,12 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != input.UserName { + if user.UserName != originalName { // 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 { + if err := s.deleteByPath(originalName); err != nil { return nil, err } } else if _, err := s.replaceUser(ctx, *user); err != nil { @@ -689,6 +736,273 @@ func (s *VaultStore) deleteByPath(username string) error { return nil } +// rolesPath is the KV prefix under which roles are stored, kept distinct +// from secretStoragePath (which holds users) so listing one entity kind +// never has to filter out the other's keys. +func (s *VaultStore) rolesPath() string { + return s.secretStoragePath + "/roles" +} + +// findRoleKey is findUserKey's counterpart for roles. +func (s *VaultStore) findRoleKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + +func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + if _, ok, err := s.findRoleKey(role.RoleName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + + role.EnsureRoleLastUsed() + + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) + } + + path := s.rolesPath() + "/" + role.RoleName + req := schema.KvV2WriteRequest{ + Data: map[string]any{role.RoleName: roleMap}, + 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.EntityAlreadyExistsRole(role.RoleName) + } + 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.EntityAlreadyExistsRole(role.RoleName) + } + 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 cloneRole(role), nil +} + +func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { + canonical, ok, err := s.findRoleKey(roleName) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + path := s.rolesPath() + "/" + canonical + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityRole(roleName) + } + 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.NoSuchEntityRole(roleName) + } + return nil, err + } + } + + role, err := parseVaultRole(resp.Data.Data, canonical) + if err != nil { + return nil, err + } + return cloneRole(role), nil +} + +func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + reauthErr := s.reAuthIfNeeded(err) + if reauthErr != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, err + } + } + + roles := make([]types.Role, 0, len(resp.Data.Keys)) + for _, key := range resp.Data.Keys { + role, err := s.GetRole(ctx, key) + if err != nil { + return nil, err + } + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though GetRole (reused + // above to fetch each entry) attaches it — matches the documented + // list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, *role) + } + + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + if len(role.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + return s.deleteRoleByPath(role.RoleName) +} + +func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + role.AssumeRolePolicyDocument = input.PolicyDocument + + return s.replaceRole(ctx, *role) +} + +// replaceRole overwrites the stored document for role.RoleName by deleting +// all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { + if err := s.deleteRoleByPath(role.RoleName); err != nil { + return nil, err + } + return s.CreateRole(ctx, role) +} + +// deleteRoleByPath permanently removes a role secret and all its versions +// without checking for existence first. +func (s *VaultStore) deleteRoleByPath(roleName string) error { + path := s.rolesPath() + "/" + roleName + _, 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 errInvalidVaultRole = errors.New("invalid role entry in vault secrets engine") + +// roleToVaultMap is userToVaultMap's counterpart for roles. +func roleToVaultMap(role types.Role) (map[string]any, error) { + b, err := json.Marshal(role) + 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 +} + +// parseVaultRole reconstructs a Role from the raw map[string]any that vault +// returns. The outer key is the role name. +func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { + raw, ok := data[roleName] + if !ok { + return types.Role{}, errInvalidVaultRole + } + roleMap, ok := raw.(map[string]any) + if !ok { + return types.Role{}, errInvalidVaultRole + } + b, err := json.Marshal(roleMap) + if err != nil { + return types.Role{}, fmt.Errorf("re-marshal vault role: %w", err) + } + var role types.Role + if err := json.Unmarshal(b, &role); err != nil { + return types.Role{}, fmt.Errorf("unmarshal vault role: %w", err) + } + return role, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/role.go b/iamapi/types/role.go new file mode 100644 index 00000000..fada0857 --- /dev/null +++ b/iamapi/types/role.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 Role struct { + Path string `xml:",omitempty"` + RoleName string `xml:",omitempty"` + RoleID string `xml:"RoleId"` + Arn string `xml:"Arn"` + CreateDate time.Time `xml:"CreateDate"` + AssumeRolePolicyDocument string `xml:",omitempty"` + Description string `xml:",omitempty"` + MaxSessionDuration int32 `xml:"MaxSessionDuration,omitempty"` + RoleLastUsed *RoleLastUsed + Tags []Tag `xml:"Tags>member,omitempty"` + Policies Policies `xml:"-"` // unused until role inline-policy CRUD exists; see DeleteRole conflict check +} + +type RoleLastUsed struct { + LastUsedDate time.Time `xml:",omitempty"` + Region string `xml:",omitempty"` +} + +// EnsureRoleLastUsed defaults RoleLastUsed to a zero value if unset, +// without clobbering an already-set value. +func (r *Role) EnsureRoleLastUsed() { + if r.RoleLastUsed == nil { + r.RoleLastUsed = &RoleLastUsed{} + } +} + +type CreateRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateRoleResponse"` + Result CreateRoleResult `xml:"CreateRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateRoleResult struct { + Role *Role +} + +type GetRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRoleResponse"` + Result GetRoleResult `xml:"GetRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRoleResult struct { + Role *Role +} + +type ListRolesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolesResponse"` + Result ListRolesResult `xml:"ListRolesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolesResult struct { + Roles Roles + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type Roles struct { + Members []Role `xml:"member"` +} + +type DeleteRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRoleResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateAssumeRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAssumeRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateAssumeRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/runiamtests.sh b/runiamtests.sh index 73dacc5a..e12a6cce 100755 --- a/runiamtests.sh +++ b/runiamtests.sh @@ -152,7 +152,7 @@ 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\"] }") + "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\", \"list\"] }") 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 diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 0a74497e..4613000e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1128,6 +1128,7 @@ func TestIAMQueryAuth(ts *TestState) { func TestIAMCreateUser(ts *TestState) { ts.Run(IAMCreateUser_user_already_exists) + ts.Run(IAMCreateUser_already_exists_case_insensitive) ts.Run(IAMCreateUser_invalid_user_name) ts.Run(IAMCreateUser_long_user_name) ts.Run(IAMCreateUser_missing_user_name) @@ -1281,6 +1282,68 @@ func TestIAMListUserPolicies(ts *TestState) { ts.Run(IAMListUserPolicies_pagination) } +func TestIAMCreateRole(ts *TestState) { + ts.Run(IAMCreateRole_missing_role_name) + ts.Run(IAMCreateRole_invalid_role_name) + ts.Run(IAMCreateRole_long_role_name) + ts.Run(IAMCreateRole_already_exists) + ts.Run(IAMCreateRole_already_exists_case_insensitive) + ts.Run(IAMCreateRole_invalid_path) + ts.Run(IAMCreateRole_long_path) + ts.Run(IAMCreateRole_missing_assume_role_policy_document) + ts.Run(IAMCreateRole_non_ascii_assume_role_policy_document) + ts.Run(IAMCreateRole_trust_policy_size_limit_exceeded) + ts.Run(IAMCreateRole_description_invalid_charset) + ts.Run(IAMCreateRole_description_too_long) + ts.Run(IAMCreateRole_max_session_duration_invalid_format) + ts.Run(IAMCreateRole_max_session_duration_too_low) + ts.Run(IAMCreateRole_max_session_duration_too_high) + ts.Run(IAMCreateRole_duplicate_tag_keys) + ts.Run(IAMCreateRole_success) + ts.Run(IAMCreateRole_defaults) + ts.Run(IAMCreateRole_trust_policy_document_grammar) +} + +func TestIAMGetRole(ts *TestState) { + ts.Run(IAMGetRole_missing_role_name) + ts.Run(IAMGetRole_invalid_role_name) + ts.Run(IAMGetRole_long_role_name) + ts.Run(IAMGetRole_non_existing_role) + ts.Run(IAMGetRole_success) +} + +func TestIAMListRoles(ts *TestState) { + ts.Run(IAMListRoles_invalid_path_prefix) + ts.Run(IAMListRoles_long_path_prefix) + ts.Run(IAMListRoles_invalid_max_items) + ts.Run(IAMListRoles_invalid_max_items_format) + ts.Run(IAMListRoles_empty_result) + ts.Run(IAMListRoles_success) + ts.Run(IAMListRoles_path_prefix) + ts.Run(IAMListRoles_pagination) + ts.Run(IAMListRoles_path_prefix_pagination) +} + +func TestIAMDeleteRole(ts *TestState) { + ts.Run(IAMDeleteRole_missing_role_name) + ts.Run(IAMDeleteRole_invalid_role_name) + ts.Run(IAMDeleteRole_long_role_name) + ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_success) +} + +func TestIAMUpdateAssumeRolePolicy(ts *TestState) { + ts.Run(IAMUpdateAssumeRolePolicy_missing_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_missing_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_invalid_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_long_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_non_existing_role) + ts.Run(IAMUpdateAssumeRolePolicy_non_ascii_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded) + ts.Run(IAMUpdateAssumeRolePolicy_success) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1298,6 +1361,11 @@ func TestIAM(ts *TestState) { TestIAMGetUserPolicy(ts) TestIAMDeleteUserPolicy(ts) TestIAMListUserPolicies(ts) + TestIAMCreateRole(ts) + TestIAMGetRole(ts) + TestIAMListRoles(ts) + TestIAMDeleteRole(ts) + TestIAMUpdateAssumeRolePolicy(ts) } func TestAccessControl(ts *TestState) { @@ -1653,6 +1721,7 @@ func GetIntTests() IntTests { "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_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, "IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name, "IAMCreateUser_long_user_name": IAMCreateUser_long_user_name, "IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name, @@ -1765,6 +1834,53 @@ func GetIntTests() IntTests { "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, "IAMListUserPolicies_success": IAMListUserPolicies_success, "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, + "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, + "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, + "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, + "IAMCreateRole_already_exists": IAMCreateRole_already_exists, + "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, + "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, + "IAMCreateRole_long_path": IAMCreateRole_long_path, + "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, + "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, + "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, + "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, + "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, + "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, + "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, + "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, + "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, + "IAMCreateRole_success": IAMCreateRole_success, + "IAMCreateRole_defaults": IAMCreateRole_defaults, + "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, + "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, + "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, + "IAMGetRole_long_role_name": IAMGetRole_long_role_name, + "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, + "IAMGetRole_success": IAMGetRole_success, + "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, + "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, + "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, + "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, + "IAMListRoles_empty_result": IAMListRoles_empty_result, + "IAMListRoles_success": IAMListRoles_success, + "IAMListRoles_path_prefix": IAMListRoles_path_prefix, + "IAMListRoles_pagination": IAMListRoles_pagination, + "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, + "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, + "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, + "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, + "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_success": IAMDeleteRole_success, + "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, + "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, + "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, + "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, + "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, + "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, + "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, + "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, + "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, "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_create_role.go b/tests/integration/iam_create_role.go new file mode 100644 index 00000000..6bddf576 --- /dev/null +++ b/tests/integration/iam_create_role.go @@ -0,0 +1,433 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/policy" +) + +// validTrustPolicyDocument is a minimal role trust policy accepted by +// ParseTrust: any principal may assume the role via sts:AssumeRole. +const validTrustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +var integrationIAMRoleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) + +func IAMCreateRole_missing_role_name(s *S3Conf) error { + testName := "IAMCreateRole_missing_role_name" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMCreateRole_invalid_role_name(s *S3Conf) error { + testName := "IAMCreateRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String("invalid/role"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMCreateRole_long_role_name(s *S3Conf) error { + testName := "IAMCreateRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMCreateRole_already_exists(s *S3Conf) error { + testName := "IAMCreateRole_already_exists" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(roleName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateRole_already_exists_case_insensitive" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + upperName := strings.ToUpper(roleName) + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &upperName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(upperName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_invalid_path(s *S3Conf) error { + testName := "IAMCreateRole_invalid_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("invalid"), + }) + return checkIAMApiErr(err, iamerr.InvalidPath("path")) + }) +} + +func IAMCreateRole_long_path(s *S3Conf) error { + testName := "IAMCreateRole_long_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("/" + strings.Repeat("a", 511) + "/"), + }) + return checkIAMApiErr(err, iamerr.PathTooLong("path", 512)) + }) +} + +func IAMCreateRole_missing_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_missing_assume_role_policy_document" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_non_ascii_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_non_ascii_assume_role_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_size_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }) +} + +func IAMCreateRole_description_invalid_charset(s *S3Conf) error { + testName := "IAMCreateRole_description_invalid_charset" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidDescriptionCharset("description")) + }) +} + +func IAMCreateRole_description_too_long(s *S3Conf) error { + testName := "IAMCreateRole_description_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String(strings.Repeat("a", 1001)), + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("description", 1000)) + }) +} + +func IAMCreateRole_max_session_duration_invalid_format(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_invalid_format" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + "MaxSessionDuration": {"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 { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMCreateRole_max_session_duration_too_low(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_low" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(3599), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooLow()) + }) +} + +func IAMCreateRole_max_session_duration_too_high(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_high" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(43201), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooHigh()) + }) +} + +func IAMCreateRole_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateRole_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + 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 IAMCreateRole_success(s *S3Conf) error { + testName := "IAMCreateRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_defaults(s *S3Conf) error { + testName := "IAMCreateRole_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/", "", 3600, validTrustPolicyDocument, false) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkCreateRoleTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkCreateRoleTrustPolicyCase verifies doc is accepted/rejected as +// expected when used as a fresh role's AssumeRolePolicyDocument. +func checkCreateRoleTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) error { + roleName := newIAMRoleName() + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if err != nil { + return fmt.Errorf("CreateRole: %w", err) + } + return deleteIAMRole(client, roleName) + } + return checkIAMApiErr(err, wantErr) +} + +func createIAMRole(client *iam.Client, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateRole(ctx, input) +} + +func newIAMRoleName() string { + return "create-role-" + genRandString(16) +} + +// checkCreateRoleOutput verifies the fields of a CreateRoleOutput-shaped role. +func checkCreateRoleOutput(out *iam.CreateRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected CreateRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("CreateRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} + +func checkRoleFields(operation string, role *iamtypes.Role, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool, requestID string, hasRequestID bool) error { + if role == nil { + return fmt.Errorf("expected %s output role", operation) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role path to be %q, instead got %q", path, aws.ToString(role.Path)) + } + if aws.ToString(role.RoleName) != roleName { + return fmt.Errorf("expected role name to be %q, instead got %q", roleName, aws.ToString(role.RoleName)) + } + expectedARN := "arn:aws:iam::000000000000:role" + path + roleName + if aws.ToString(role.Arn) != expectedARN { + return fmt.Errorf("expected role ARN to be %q, instead got %q", expectedARN, aws.ToString(role.Arn)) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected role create date") + } + if aws.ToString(role.Description) != description { + return fmt.Errorf("expected role description to be %q, instead got %q", description, aws.ToString(role.Description)) + } + if aws.ToInt32(role.MaxSessionDuration) != maxSessionDuration { + return fmt.Errorf("expected role max session duration to be %d, instead got %d", maxSessionDuration, aws.ToInt32(role.MaxSessionDuration)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(role.AssumeRolePolicyDocument), err) + } + if gotDocument != wantDocument { + return fmt.Errorf("expected assume role policy document %q, instead got %q", wantDocument, gotDocument) + } + if role.RoleLastUsed == nil { + return fmt.Errorf("expected role RoleLastUsed to be non-nil (empty element)") + } + if expectTags { + if len(role.Tags) != 1 || aws.ToString(role.Tags[0].Key) != "env" || aws.ToString(role.Tags[0].Value) != "test" { + return fmt.Errorf("expected role tag env=test, instead got %#v", role.Tags) + } + } else if len(role.Tags) != 0 { + return fmt.Errorf("expected no role tags, instead got %#v", role.Tags) + } + if !hasRequestID || requestID == "" { + return fmt.Errorf("expected %s response request id", operation) + } + + return nil +} diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go index 9f64cae1..ca1f435e 100644 --- a/tests/integration/iam_create_user.go +++ b/tests/integration/iam_create_user.go @@ -47,6 +47,27 @@ func IAMCreateUser_user_already_exists(s *S3Conf) error { }) } +func IAMCreateUser_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateUser_already_exists_case_insensitive" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{ + UserName: &userName, + }); err != nil { + return err + } + + upperName := strings.ToUpper(userName) + _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &upperName}) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsUser(upperName)) + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + func IAMCreateUser_invalid_user_name(s *S3Conf) error { testName := "IAMCreateUser_invalid_user_name" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role.go b/tests/integration/iam_delete_role.go new file mode 100644 index 00000000..ecb30031 --- /dev/null +++ b/tests/integration/iam_delete_role.go @@ -0,0 +1,95 @@ +// 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" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteRole_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_missing_role_name" + body := []byte("Action=DeleteRole&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.MissingParameter("RoleName")) + }) +} + +func IAMDeleteRole_invalid_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMDeleteRole_long_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMDeleteRole_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + err := deleteIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRole_success(s *S3Conf) error { + testName := "IAMDeleteRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + if err := deleteIAMRole(client, roleName); err != nil { + return err + } + + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func deleteIAMRole(client *iam.Client, roleName string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: &roleName}) + return err +} diff --git a/tests/integration/iam_get_role.go b/tests/integration/iam_get_role.go new file mode 100644 index 00000000..4e20525f --- /dev/null +++ b/tests/integration/iam_get_role.go @@ -0,0 +1,122 @@ +// 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" + "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 IAMGetRole_missing_role_name(s *S3Conf) error { + testName := "IAMGetRole_missing_role_name" + body := []byte("Action=GetRole&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.MissingParameter("RoleName")) + }) +} + +func IAMGetRole_invalid_role_name(s *S3Conf) error { + testName := "IAMGetRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMGetRole_long_role_name(s *S3Conf) error { + testName := "IAMGetRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMGetRole_non_existing_role(s *S3Conf) error { + testName := "IAMGetRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRole_success(s *S3Conf) error { + testName := "IAMGetRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }); err != nil { + return err + } + + out, err := getIAMRole(client, roleName) + if err != nil { + deleteErr := deleteIAMRole(client, roleName) + if deleteErr != nil { + return fmt.Errorf("get role: %v; delete role: %w", err, deleteErr) + } + return err + } + + checkErr := checkGetRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRole(client *iam.Client, roleName string) (*iam.GetRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRole(ctx, &iam.GetRoleInput{RoleName: &roleName}) +} + +// checkGetRoleOutput verifies the fields of a GetRoleOutput-shaped role. +func checkGetRoleOutput(out *iam.GetRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected GetRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("GetRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} diff --git a/tests/integration/iam_list_roles.go b/tests/integration/iam_list_roles.go new file mode 100644 index 00000000..0849a288 --- /dev/null +++ b/tests/integration/iam_list_roles.go @@ -0,0 +1,375 @@ +// 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 IAMListRoles_invalid_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_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 := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: aws.String(pathPrefix)}) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("PathPrefix %q: %w", pathPrefix, checkErr) + } + } + return nil + }) +} + +func IAMListRoles_long_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_long_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/" + strings.Repeat("a", 512) + _, err := listIAMRoles(client, &iam.ListRolesInput{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 IAMListRoles_invalid_max_items(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, maxItems := range []int32{-1, 0, 1001} { + _, err := listIAMRoles(client, &iam.ListRolesInput{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 IAMListRoles_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListRoles"}, + "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 IAMListRoles_empty_result(s *S3Conf) error { + testName := "IAMListRoles_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/list-roles-" + genRandString(16) + "/" + input := &iam.ListRolesInput{PathPrefix: &pathPrefix} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRolesOutput(second); err != nil { + return err + } + if len(first.Roles) != 0 || len(second.Roles) != 0 { + return fmt.Errorf("expected consistent empty results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) +} + +func IAMListRoles_success(s *S3Conf) error { + testName := "IAMListRoles_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := map[string]string{"list-roles-" + genRandString(16): path} + return withIAMListRoles(client, roles, func() error { + out, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: &path}) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(out); err != nil { + return err + } + return checkIAMListRoles(out.Roles, roles) + }) + }) +} + +func IAMListRoles_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + engineeringPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := 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 withIAMListRoles(client, roles, func() error { + input := &iam.ListRolesInput{PathPrefix: &engineeringPath} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRoles(first.Roles, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) { + return fmt.Errorf("expected consistent results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) + }) +} + +func IAMListRoles_pagination(s *S3Conf) error { + testName := "IAMListRoles_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := make(map[string]string, 5) + for range 5 { + roles["list-roles-"+genRandString(16)] = path + } + return withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &path, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, roles); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent pagination results") + } + return nil + }) + }) +} + +func IAMListRoles_path_prefix_pagination(s *S3Conf) error { + testName := "IAMListRoles_path_prefix_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + matchingPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := 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 withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &matchingPath, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent filtered pagination results") + } + return nil + }) + }) +} + +func listIAMRoles(client *iam.Client, input *iam.ListRolesInput) (*iam.ListRolesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRoles(ctx, input) +} + +func withIAMListRoles(client *iam.Client, roles map[string]string, test func() error) (err error) { + created := make([]string, 0, len(roles)) + defer func() { + for _, name := range created { + if deleteErr := deleteIAMRole(client, name); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM role %q: %w", name, deleteErr)) + } + } + }() + + for name, path := range roles { + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &name, + Path: &path, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + created = append(created, name) + } + return test() +} + +func collectIAMListRolePages(client *iam.Client, input iam.ListRolesInput) ([]*iam.ListRolesOutput, error) { + var pages []*iam.ListRolesOutput + for { + out, err := listIAMRoles(client, &input) + if err != nil { + return nil, err + } + if err := checkIAMListRolesOutput(out); err != nil { + return nil, err + } + pages = append(pages, out) + if !out.IsTruncated { + return pages, nil + } + input.Marker = out.Marker + } +} + +func checkIAMListRolesOutput(out *iam.ListRolesOutput) error { + if out == nil { + return fmt.Errorf("expected ListRoles output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRoles response request id") + } + if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") { + return fmt.Errorf("expected marker only when ListRoles output is truncated") + } + for _, role := range out.Roles { + if aws.ToString(role.Path) == "" || aws.ToString(role.RoleName) == "" || aws.ToString(role.RoleId) == "" || aws.ToString(role.Arn) == "" || role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected all required fields for listed role, instead got %#v", role) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.RoleLastUsed != nil { + return fmt.Errorf("expected ListRoles RoleLastUsed to be nil (list/get asymmetry), instead got %#v", role.RoleLastUsed) + } + } + return nil +} + +func checkIAMListRoles(roles []iamtypes.Role, expected map[string]string) error { + if len(roles) != len(expected) { + return fmt.Errorf("expected %d roles, instead got %d: %v", len(expected), len(roles), iamListRoleNames(roles)) + } + names := iamListRoleNames(roles) + if !sort.StringsAreSorted(names) { + return fmt.Errorf("expected roles sorted by role name, instead got %v", names) + } + for _, role := range roles { + name := aws.ToString(role.RoleName) + path, ok := expected[name] + if !ok { + return fmt.Errorf("unexpected listed role %q", name) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role %q path %q, instead got %q", name, path, aws.ToString(role.Path)) + } + if want := "arn:aws:iam::000000000000:role" + path + name; aws.ToString(role.Arn) != want { + return fmt.Errorf("expected role %q ARN %q, instead got %q", name, want, aws.ToString(role.Arn)) + } + } + return nil +} + +func checkIAMListRolePages(pages []*iam.ListRolesOutput, 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 roles []iamtypes.Role + for i, page := range pages { + if len(page.Roles) != sizes[i] { + return fmt.Errorf("expected page %d to contain %d roles, instead got %d", i+1, sizes[i], len(page.Roles)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + roles = append(roles, page.Roles...) + } + return checkIAMListRoles(roles, expected) +} + +func iamListRolePageValues(pages []*iam.ListRolesOutput) [][]string { + values := make([][]string, len(pages)) + for i, page := range pages { + values[i] = append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListRoleNames(page.Roles)...) + } + return values +} + +func iamListRoleNames(roles []iamtypes.Role) []string { + names := make([]string, len(roles)) + for i, role := range roles { + names[i] = aws.ToString(role.RoleName) + } + return names +} diff --git a/tests/integration/iam_update_assume_role_policy.go b/tests/integration/iam_update_assume_role_policy.go new file mode 100644 index 00000000..2935e0f5 --- /dev/null +++ b/tests/integration/iam_update_assume_role_policy.go @@ -0,0 +1,253 @@ +// 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" + "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" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" +) + +func IAMUpdateAssumeRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyDocument": {validTrustPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMUpdateAssumeRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_invalid_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("invalid/role"), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMUpdateAssumeRolePolicy_long_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(strings.Repeat("a", 129)), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("asdfadsf"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateAssumeRolePolicy_success(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + created, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := func() error { + const updatedDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + out, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(updatedDocument), + }) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected UpdateAssumeRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UpdateAssumeRolePolicy response request id") + } + + got, err := getIAMRole(client, roleName) + if err != nil { + return err + } + if got == nil || got.Role == nil || created == nil || created.Role == nil { + return fmt.Errorf("expected created and updated roles") + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.Role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(got.Role.AssumeRolePolicyDocument), err) + } + if gotDocument != updatedDocument { + return fmt.Errorf("expected updated assume role policy document %q, instead got %q", updatedDocument, gotDocument) + } + if aws.ToString(got.Role.RoleId) != aws.ToString(created.Role.RoleId) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role id, want %q, instead got %q", aws.ToString(created.Role.RoleId), aws.ToString(got.Role.RoleId)) + } + if got.Role.CreateDate == nil || created.Role.CreateDate == nil || !got.Role.CreateDate.Equal(*created.Role.CreateDate) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role create date") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateIAMAssumeRolePolicy(client *iam.Client, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UpdateAssumeRolePolicy(ctx, input) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkUpdateAssumeRolePolicyTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkUpdateAssumeRolePolicyTrustPolicyCase verifies doc is accepted/rejected +// as expected when used to update an existing role's trust policy. +func checkUpdateAssumeRolePolicyTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) (err error) { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("create base role: %w", err) + } + defer func() { + if deleteErr := deleteIAMRole(client, roleName); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("cleanup: %w", deleteErr)) + } + }() + + _, updateErr := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if updateErr != nil { + return fmt.Errorf("UpdateAssumeRolePolicy: %w", updateErr) + } + return nil + } + return checkIAMApiErr(updateErr, wantErr) +} diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 2a2e4856..48e5f63d 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -934,6 +934,60 @@ func checkIAMApiErr(err error, expected iamerr.APIError) error { return nil } +type trustPolicyGrammarCase struct { + name string + doc string + wantErr iamerr.APIError // nil means the document must be accepted +} + +// trustPolicyGrammarCases covers the role trust-policy grammar +var trustPolicyGrammarCases = []trustPolicyGrammarCase{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + {"unrelated condition block ignored (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"aws:SourceAccount":"123456789012"}}}]}`, nil}, + + {"invalid json syntax", `{invalid json`, iamerr.MalformedPolicyDocument("This policy contains invalid Json")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The policy must contain a valid version string")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Effect")}, + + {"missing principal (opposite of an identity policy, which forbids it)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal")}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!")}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Principal must be a JSON object.")}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Syntax error in policy.")}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal key wrong case (looks like it should work, key match is case-sensitive)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "service"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (valid on real AWS, unsupported by this gateway)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.")}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, iamerr.MalformedPolicyDocument("Missing required field Action")}, + {"bare wildcard action rejected (legal in an identity policy, not here)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"one non-sts action in an otherwise-valid array rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","s3:GetObject"]}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Has prohibited field Resource")}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.")}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique")}, + + {"cognito federated without condition (looks valid, Cognito needs a Condition)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider")}, +} + func putObjects(client *s3.Client, objs []string, bucket string) ([]types.Object, error) { var contents []types.Object var size int64 From c9ce6ab37c253f7446ed7529c831daeeea22767d Mon Sep 17 00:00:00 2001 From: niksis02 Date: Thu, 16 Jul 2026 22:33:50 +0400 Subject: [PATCH 05/10] feat: add IAM role inline policy CRUD Add support for the `PutRolePolicy`, `GetRolePolicy`, `DeleteRolePolicy`, and `ListRolePolicies` actions in the IAM-compatible gateway service, extending role management with the same inline-policy lifecycle already available for IAM users. `PutRolePolicy` validates the policy name and document, parses the document for AWS-compatible syntax and semantic errors (missing actions/resources, malformed ARNs, disallowed principals, duplicate statement IDs, and so on), and rejects documents once the role's aggregate inline-policy size would exceed `MaxInlinePolicyBytesPerRole` (10240 bytes, distinct from the 2048-byte quota enforced for users). Putting a policy under an existing name overwrites its document in place. `GetRolePolicy` and `DeleteRolePolicy` look up or remove a named inline policy from a role, returning a `NoSuchEntity` error when the role or the policy is not found. `ListRolePolicies` returns a role's inline policy names in sorted order with marker-based pagination. These actions are implemented for both the internal file-backed store and the Vault-backed store, wired into the IAM API router, and given their own XML response types under `iamapi/types`. A new `NoSuchEntityRolePolicy` error was added to `iamapi/iamerr` to mirror the existing user-policy error. --- iamapi/controller.go | 130 +++++++ iamapi/controller_test.go | 370 +++++++++++++++++++ iamapi/iamerr/errors.go | 4 + iamapi/router.go | 5 + iamapi/storage/internal.go | 153 ++++++++ iamapi/storage/storer.go | 27 ++ iamapi/storage/storer_test.go | 127 +++++++ iamapi/storage/vault.go | 116 ++++++ iamapi/types/policy.go | 50 +++ tests/integration/group-tests.go | 76 ++++ tests/integration/iam_delete_role.go | 33 ++ tests/integration/iam_delete_role_policy.go | 212 +++++++++++ tests/integration/iam_get_role_policy.go | 171 +++++++++ tests/integration/iam_list_role_policies.go | 235 ++++++++++++ tests/integration/iam_put_role_policy.go | 389 ++++++++++++++++++++ 15 files changed, 2098 insertions(+) create mode 100644 tests/integration/iam_delete_role_policy.go create mode 100644 tests/integration/iam_get_role_policy.go create mode 100644 tests/integration/iam_list_role_policies.go create mode 100644 tests/integration/iam_put_role_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index f224d24b..0cd44868 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -718,3 +718,133 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil } + +func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutRolePolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required PutRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + // Confirm the role exists before inspecting policy document content + if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to get IAM role %q for PutRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutRolePolicy(ctx.Context(), storage.PutRolePolicyInput{ + RoleName: roleName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.PutRolePolicyResponse{}}, nil +} + +func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetRolePolicy(ctx.Context(), roleName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.GetRolePolicyResponse{ + Result: types.GetRolePolicyResult{ + RoleName: roleName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRolePolicy(ctx.Context(), roleName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRolePolicyResponse{}}, nil +} + +func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRolePolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRolePolicies(ctx.Context(), storage.ListRolePoliciesInput{ + RoleName: roleName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM role policies for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.ListRolePoliciesResponse{ + Result: types.ListRolePoliciesResult{ + PolicyNames: types.PolicyNameList{Members: out.PolicyNames}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index dd975302..3c0cefa8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -1301,6 +1301,376 @@ func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { } } +func TestIAMApiControllerRolePolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createRole := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if createRole.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createRole.StatusCode, readBody(t, createRole)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutRolePolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutRolePolicyResponse" { + t.Fatalf("PutRolePolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutRolePolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRolePolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRolePolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.RoleName != "my-role" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetRolePolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetRolePolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument) + } + decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if decoded != policyDoc { + t.Fatalf("GetRolePolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRolePolicies"}, + "RoleName": {"my-role"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRolePolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolePoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListRolePolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListRolePolicies IsTruncated = true, want false") + } + + // Re-Put-ing the same PolicyName replaces it rather than erroring or + // stacking toward the aggregate size quota. + overwritePut := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutRolePolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetRolePolicyResponse + unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut) + overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if !strings.Contains(overwriteDecoded, "Deny") { + t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteRolePolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteRolePolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRolePolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.") + + // A second delete of the same (now-gone) policy is a hard error, not an + // idempotent success. + doubleDelete := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupRole bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing policy name", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing role name", + params: url.Values{"Action": {"PutRolePolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "put long policy name", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "put non-ascii policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.", + }, + { + name: "put role does not exist", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put nonexistent role wins over malformed document", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": { + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, + }}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Policy document should not specify a principal.", + }, + { + name: "get role does not exist", + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupRole: true, + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete role does not exist", + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupRole: true, + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list role does not exist", + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupRole: true, + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupRole { + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerDeleteRolePolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deleteRole := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + requireIAMError(t, deleteRole, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteRoleAfter := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + if deleteRoleAfter.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteRoleAfter.StatusCode, readBody(t, deleteRoleAfter)) + } +} + +func TestIAMApiControllerPutRolePolicyOversizedDocument(t *testing.T) { + // A >131072 byte PolicyDocument does not fit in a GET query string + // against this test server's header/URL read-buffer limit, matching + // real IAM's own guidance to use POST rather than GET for large + // policy documents - so this one case is exercised over POST directly + // rather than through the doIAMAction GET helper used elsewhere. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {strings.Repeat("x", 131073)}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072") +} + +func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { + // The role's aggregate inline-policy quota (10240 bytes) is well over + // this test server's GET header/URL read-buffer limit, so this case + // is exercised over POST, same as TestIAMApiControllerPutRolePolicyOversizedDocument. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {oversizedDoc}, + }) + requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 4c919a0e..1df3877e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -453,6 +453,10 @@ func NoSuchEntityUserPolicy(userName, policyName string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) } +func NoSuchEntityRolePolicy(roleName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role policy with name %s cannot be found.", policyName), http.StatusNotFound) +} + func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error { return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) } diff --git a/iamapi/router.go b/iamapi/router.go index 4e6444e9..ddb39bd2 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -68,6 +68,11 @@ func (r *IAMApiRouter) Init() { "ListRoles": ctrl.ListRoles, "DeleteRole": ctrl.DeleteRole, "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + // Role Inline Policy CRUD + "PutRolePolicy": ctrl.PutRolePolicy, + "GetRolePolicy": ctrl.GetRolePolicy, + "DeleteRolePolicy": ctrl.DeleteRolePolicy, + "ListRolePolicies": ctrl.ListRolePolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 81a76a61..536418d8 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -816,6 +816,159 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs return cloneRole(updated), nil } +func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) 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 + } + + canonical, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return nil, iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetRolePolicy(_ context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *InternalStore) DeleteRolePolicy(_ context.Context, roleName, policyName 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 + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListRolePolicies(_ context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolePoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index f862d00f..aa915c19 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -33,6 +33,10 @@ const MaxAccessKeysPerUser = 2 // all of a single IAM user's inline policy documents combined const MaxInlinePolicyBytesPerUser = 2048 +// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of +// all of a single IAM role's inline policy documents combined +const MaxInlinePolicyBytesPerRole = 10240 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -126,6 +130,24 @@ type UpdateAssumeRolePolicyInput struct { PolicyDocument string } +type PutRolePolicyInput struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListRolePoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -150,6 +172,11 @@ type Storer interface { ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) DeleteRole(ctx context.Context, roleName string) error UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) + + PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error + GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) + DeleteRolePolicy(ctx context.Context, roleName, policyName string) error + ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index e54104a0..59c95a56 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -384,3 +384,130 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreRolePolicyCRUD(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateRole(ctx, types.Role{ + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "ALICE-ROLE", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "missing-role", PolicyName: "P", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("PutRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + entry, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy: %v", err) + } + if entry.PolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` { + t.Fatalf("GetRolePolicy document = %q", entry.PolicyDocument) + } + if entry.CreateDate.IsZero() || entry.UpdateDate.IsZero() { + t.Fatalf("GetRolePolicy CreateDate/UpdateDate zero: %#v", entry) + } + if _, err := store.GetRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("GetRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if _, err := store.GetRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("GetRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + // Overwriting an existing PolicyName replaces its document rather than + // stacking toward the aggregate size quota. + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("overwrite PutRolePolicy: %v", err) + } + overwritten, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy after overwrite: %v", err) + } + if !strings.Contains(overwritten.PolicyDocument, "Deny") { + t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwritten.PolicyDocument) + } + + // Aggregate inline policy size for a role is capped at + // MaxInlinePolicyBytesPerRole (10240), distinct from and larger than + // the 2048 byte cap for users. + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "alice-role", PolicyName: "TooBig", PolicyDocument: oversized}); !errors.Is(err, iamerr.InlinePolicyQuotaExceeded("role", "alice-role", MaxInlinePolicyBytesPerRole)) { + t.Fatalf("PutRolePolicy oversized err = %v, want LimitExceeded", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "SecondPolicy", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy second policy: %v", err) + } + + list, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "ALICE-ROLE", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRolePolicies page1: %v", err) + } + if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "ReadOnly" || !list.IsTruncated || list.Marker != "ReadOnly" { + t.Fatalf("ListRolePolicies page1 = %#v, want truncated ReadOnly page", list) + } + page2, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "alice-role", Marker: list.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRolePolicies page2: %v", err) + } + if len(page2.PolicyNames) != 1 || page2.PolicyNames[0] != "SecondPolicy" || page2.IsTruncated { + t.Fatalf("ListRolePolicies page2 = %#v, want final SecondPolicy page", page2) + } + if _, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "missing-role"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("ListRolePolicies missing role err = %v, want NoSuchEntity", err) + } + + // A role with attached inline policies cannot be deleted until they are + // all removed first. + if err := store.DeleteRole(ctx, "alice-role"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) { + t.Fatalf("DeleteRole with policies err = %v, want DeleteConflict", err) + } + + if err := store.DeleteRolePolicy(ctx, "alice-role", "SecondPolicy"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := store.DeleteRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("DeleteRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if err := store.DeleteRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("DeleteRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + if _, err := reopened.GetRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("GetRolePolicy after reopen: %v", err) + } + + if err := reopened.DeleteRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := reopened.DeleteRole(ctx, "alice-role"); err != nil { + t.Fatalf("DeleteRole after removing all policies: %v", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index d54c97a2..9c914d85 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -940,6 +940,122 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss return s.replaceRole(ctx, *role) } +func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return nil, err + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolePoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + // replaceRole overwrites the stored document for role.RoleName by deleting // all existing versions and recreating with CAS=0. func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go index a7149e81..863084ec 100644 --- a/iamapi/types/policy.go +++ b/iamapi/types/policy.go @@ -94,3 +94,53 @@ type ListUserPoliciesResult struct { type PolicyNameList struct { Members []string `xml:"member"` } + +type PutRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRolePolicyResponse"` + Result GetRolePolicyResult `xml:"GetRolePolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResult struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolePoliciesResponse"` + Result ListRolePoliciesResult `xml:"ListRolePoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolePoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolePoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 4613000e..9d618d75 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1329,6 +1329,7 @@ func TestIAMDeleteRole(ts *TestState) { ts.Run(IAMDeleteRole_invalid_role_name) ts.Run(IAMDeleteRole_long_role_name) ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_has_policies) ts.Run(IAMDeleteRole_success) } @@ -1344,6 +1345,47 @@ func TestIAMUpdateAssumeRolePolicy(ts *TestState) { ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) } +func TestIAMPutRolePolicy(ts *TestState) { + ts.Run(IAMPutRolePolicy_missing_role_name) + ts.Run(IAMPutRolePolicy_missing_policy_name) + ts.Run(IAMPutRolePolicy_missing_policy_document) + ts.Run(IAMPutRolePolicy_invalid_policy_name) + ts.Run(IAMPutRolePolicy_long_policy_name) + ts.Run(IAMPutRolePolicy_non_ascii_policy_document) + ts.Run(IAMPutRolePolicy_non_existing_role) + ts.Run(IAMPutRolePolicy_malformed_policy_document) + ts.Run(IAMPutRolePolicy_principal_not_allowed) + ts.Run(IAMPutRolePolicy_limit_exceeded) + ts.Run(IAMPutRolePolicy_success) + ts.Run(IAMPutRolePolicy_overwrite_updates_existing) +} + +func TestIAMGetRolePolicy(ts *TestState) { + ts.Run(IAMGetRolePolicy_missing_role_name) + ts.Run(IAMGetRolePolicy_missing_policy_name) + ts.Run(IAMGetRolePolicy_non_existing_role) + ts.Run(IAMGetRolePolicy_non_existing_policy) + ts.Run(IAMGetRolePolicy_success) +} + +func TestIAMDeleteRolePolicy(ts *TestState) { + ts.Run(IAMDeleteRolePolicy_missing_role_name) + ts.Run(IAMDeleteRolePolicy_missing_policy_name) + ts.Run(IAMDeleteRolePolicy_non_existing_role) + ts.Run(IAMDeleteRolePolicy_non_existing_policy) + ts.Run(IAMDeleteRolePolicy_success) + ts.Run(IAMDeleteRolePolicy_blocks_role_deletion) +} + +func TestIAMListRolePolicies(ts *TestState) { + ts.Run(IAMListRolePolicies_missing_role_name) + ts.Run(IAMListRolePolicies_non_existing_role) + ts.Run(IAMListRolePolicies_invalid_max_items) + ts.Run(IAMListRolePolicies_empty_result) + ts.Run(IAMListRolePolicies_success) + ts.Run(IAMListRolePolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1366,6 +1408,10 @@ func TestIAM(ts *TestState) { TestIAMListRoles(ts) TestIAMDeleteRole(ts) TestIAMUpdateAssumeRolePolicy(ts) + TestIAMPutRolePolicy(ts) + TestIAMGetRolePolicy(ts) + TestIAMDeleteRolePolicy(ts) + TestIAMListRolePolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1871,6 +1917,7 @@ func GetIntTests() IntTests { "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, "IAMDeleteRole_success": IAMDeleteRole_success, "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, @@ -1881,6 +1928,35 @@ func GetIntTests() IntTests { "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, + "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, + "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, + "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, + "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, + "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, + "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, + "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, + "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, + "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, + "IAMPutRolePolicy_success": IAMPutRolePolicy_success, + "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, + "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, + "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, + "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, + "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, + "IAMGetRolePolicy_success": IAMGetRolePolicy_success, + "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, + "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, + "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, + "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, + "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, + "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, + "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, + "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, + "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, + "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, + "IAMListRolePolicies_success": IAMListRolePolicies_success, + "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, "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_delete_role.go b/tests/integration/iam_delete_role.go index ecb30031..a0e5c57a 100644 --- a/tests/integration/iam_delete_role.go +++ b/tests/integration/iam_delete_role.go @@ -67,6 +67,39 @@ func IAMDeleteRole_non_existing_role(s *S3Conf) error { }) } +func IAMDeleteRole_has_policies(s *S3Conf) error { + testName := "IAMDeleteRole_has_policies" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + func IAMDeleteRole_success(s *S3Conf) error { testName := "IAMDeleteRole_success" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role_policy.go b/tests/integration/iam_delete_role_policy.go new file mode 100644 index 00000000..b0277cb8 --- /dev/null +++ b/tests/integration/iam_delete_role_policy.go @@ -0,0 +1,212 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMDeleteRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMDeleteRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_success(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteRolePolicy response request id") + } + + _, err = getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRolePolicy(roleName, "p")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_blocks_role_deletion(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_blocks_role_deletion" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + +func deleteIAMRolePolicyRaw(client *iam.Client, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteRolePolicy(ctx, input) +} + +func deleteIAMRolePolicy(client *iam.Client, roleName, policyName string) error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: &policyName}) + return err +} + +// deleteIAMRoleAndPolicies deletes all of the role's inline policies before +// deleting the role, since DeleteRole rejects roles with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMRoleAndPolicies(client *iam.Client, roleName string) error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMRolePolicy(client, roleName, policyName); err != nil { + return err + } + } + return deleteIAMRole(client, roleName) +} diff --git a/tests/integration/iam_get_role_policy.go b/tests/integration/iam_get_role_policy.go new file mode 100644 index 00000000..29ab2826 --- /dev/null +++ b/tests/integration/iam_get_role_policy.go @@ -0,0 +1,171 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMGetRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMGetRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetRolePolicy_success(s *S3Conf) error { + testName := "IAMGetRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetRolePolicy output") + } + if aws.ToString(out.RoleName) != roleName { + return fmt.Errorf("expected role name %q, instead got %q", roleName, aws.ToString(out.RoleName)) + } + if aws.ToString(out.PolicyName) != "ReadOnly" { + return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetRolePolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRolePolicy(client *iam.Client, input *iam.GetRolePolicyInput) (*iam.GetRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRolePolicy(ctx, input) +} diff --git a/tests/integration/iam_list_role_policies.go b/tests/integration/iam_list_role_policies.go new file mode 100644 index 00000000..bbc68da8 --- /dev/null +++ b/tests/integration/iam_list_role_policies.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 integration + +import ( + "context" + "fmt" + "net/http" + "slices" + "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" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListRolePolicies_missing_role_name(s *S3Conf) error { + testName := "IAMListRolePolicies_missing_role_name" + body := []byte("Action=ListRolePolicies&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.MissingValue("roleName")) + }) +} + +func IAMListRolePolicies_non_existing_role(s *S3Conf) error { + testName := "IAMListRolePolicies_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMListRolePolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListRolePolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_empty_result(s *S3Conf) error { + testName := "IAMListRolePolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + if len(out.PolicyNames) != 0 { + return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_success(s *S3Conf) error { + testName := "IAMListRolePolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRolePolicies response request id") + } + got := slices.Clone(out.PolicyNames) + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_pagination(s *S3Conf) error { + testName := "IAMListRolePolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1)} + var pages []*iam.ListRolePoliciesOutput + for { + out, err := listIAMRolePolicies(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.PolicyNames) != 1 { + return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, page.PolicyNames...) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMRolePolicies(client *iam.Client, input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRolePolicies(ctx, input) +} diff --git a/tests/integration/iam_put_role_policy.go b/tests/integration/iam_put_role_policy.go new file mode 100644 index 00000000..89ba286d --- /dev/null +++ b/tests/integration/iam_put_role_policy.go @@ -0,0 +1,389 @@ +// 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" + "net/url" + "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" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMPutRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + "PolicyDocument": {validIAMPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMPutRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "PolicyDocument": {validIAMPolicyDocument}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMPutRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "PolicyName": {"p"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMPutRolePolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutRolePolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMPutRolePolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_malformed_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + cases := []struct { + name string + doc string + wantErr iamerr.APIError + }{ + {"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")}, + } + + for _, c := range cases { + if err := func() error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(c.doc), + }) + if err := checkIAMApiErr(err, c.wantErr); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutRolePolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutRolePolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutRolePolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10500) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("role", roleName, storage.MaxInlinePolicyBytesPerRole)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_success(s *S3Conf) error { + testName := "IAMPutRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + out, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutRolePolicy response request id") + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutRolePolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}` + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != updated { + return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMRolePolicy(client *iam.Client, input *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutRolePolicy(ctx, input) +} From ec579ca8411d9fcd7693a3345c626c90ddfa9781 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 21 Jul 2026 22:55:49 +0400 Subject: [PATCH 06/10] feat: add IAM OIDC provider CRUD Add support for `CreateOpenIDConnectProvider`, `GetOpenIDConnectProvider`, `ListOpenIDConnectProviders`, `DeleteOpenIDConnectProvider`, `AddClientIDToOpenIDConnectProvider`, `RemoveClientIDFromOpenIDConnectProvider`, and `UpdateOpenIDConnectProviderThumbprint` on both the internal and Vault storage backends, rounding out the standalone IAM service with the same OIDC identity provider management AWS IAM exposes. CreateOpenIDConnectProvider validates the issuer URL, enforces the client ID and per-provider client ID list limits, and accepts an optional ThumbprintList. When the caller omits ThumbprintList, the provider auto-fetches the thumbprint by opening an outbound TLS connection to the issuer URL and hashing its top-level CA certificate, matching real AWS behavior. This auto-fetch is configurable: it can be turned off with the `--disable-oidc-thumbprint-autofetch` CLI flag (or the `VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH` environment variable) for restricted or air-gapped deployments where the IAM server shouldn't make outbound connections, in which case an omitted ThumbprintList is rejected instead. AddClientIDToOpenIDConnectProvider and RemoveClientIDFromOpenIDConnectProvider manage a provider's client ID list, and UpdateOpenIDConnectProviderThumbprint replaces its thumbprint list, all with the same length and format validation applied at creation time. Provider ARNs are derived from the issuer URL, and GetOpenIDConnectProvider and DeleteOpenIDConnectProvider resolve providers by ARN, returning NoSuchEntity when a provider doesn't exist. ListOpenIDConnectProviders returns the full set of stored providers. These actions are wired into the IAM API router and given their own XML response types under iamapi/types, with a new iamapi/internal/iamutil package handling URL validation, thumbprint fetching and normalization, and ARN construction shared across the controller methods. --- cmd/internal/gwcli/iam.go | 5 + cmd/versitygw/iam.go | 59 +-- embedgw/iam.go | 10 + iamapi/controller.go | 198 ++++++- iamapi/controller_test.go | 330 ++++++++++++ iamapi/iamerr/errors.go | 45 ++ iamapi/internal/iamutil/oidc.go | 225 ++++++++ iamapi/internal/iamutil/oidc_thumbprint.go | 127 +++++ .../internal/iamutil/oidc_thumbprint_test.go | 122 +++++ iamapi/router.go | 60 ++- iamapi/server.go | 13 + iamapi/storage/internal.go | 190 +++++++ iamapi/storage/storer.go | 21 + iamapi/storage/vault.go | 292 +++++++++++ iamapi/types/oidc.go | 129 +++++ tests/integration/group-tests.go | 114 +++++ .../iam_add_client_id_to_oidc_provider.go | 204 ++++++++ tests/integration/iam_create_oidc_provider.go | 481 ++++++++++++++++++ tests/integration/iam_delete_oidc_provider.go | 84 +++ tests/integration/iam_get_oidc_provider.go | 143 ++++++ tests/integration/iam_list_oidc_providers.go | 122 +++++ ...iam_remove_client_id_from_oidc_provider.go | 163 ++++++ .../iam_update_oidc_provider_thumbprint.go | 185 +++++++ 23 files changed, 3266 insertions(+), 56 deletions(-) create mode 100644 iamapi/internal/iamutil/oidc.go create mode 100644 iamapi/internal/iamutil/oidc_thumbprint.go create mode 100644 iamapi/internal/iamutil/oidc_thumbprint_test.go create mode 100644 iamapi/types/oidc.go create mode 100644 tests/integration/iam_add_client_id_to_oidc_provider.go create mode 100644 tests/integration/iam_create_oidc_provider.go create mode 100644 tests/integration/iam_delete_oidc_provider.go create mode 100644 tests/integration/iam_get_oidc_provider.go create mode 100644 tests/integration/iam_list_oidc_providers.go create mode 100644 tests/integration/iam_remove_client_id_from_oidc_provider.go create mode 100644 tests/integration/iam_update_oidc_provider_thumbprint.go diff --git a/cmd/internal/gwcli/iam.go b/cmd/internal/gwcli/iam.go index 705ad80d..183aa5f0 100644 --- a/cmd/internal/gwcli/iam.go +++ b/cmd/internal/gwcli/iam.go @@ -109,6 +109,11 @@ func IAMCommand() *cli.Command { EnvVars: []string{"VGW_QUIET"}, Aliases: []string{"q"}, }, + &cli.BoolFlag{ + Name: "disable-oidc-thumbprint-autofetch", + Usage: "reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection", + EnvVars: []string{"VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH"}, + }, }, } } diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index c80d098b..5b246165 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -34,34 +34,35 @@ func runIAM(ctx *cli.Context) error { } return embedgw.RunIAMAPI(ctx.Context, &embedgw.IAMConfig{ - RootUserAccess: gwcli.RootUserAccess, - RootUserSecret: gwcli.RootUserSecret, - Ports: ports, - MaxConnections: maxConnections, - MaxRequests: maxRequests, - CertFile: certFile, - KeyFile: keyFile, - Debug: debug, - Quiet: quiet || ctx.Bool("quiet"), - KeepAlive: keepAlive, - HealthPath: healthPath, - SocketPerm: socketPerm, - IAMDir: ctx.String("dir"), - VaultEndpointURL: ctx.String("vault-endpoint-url"), - VaultNamespace: ctx.String("vault-namespace"), - VaultSecretStoragePath: ctx.String("vault-secret-storage-path"), - VaultSecretStorageNamespace: ctx.String("vault-secret-storage-namespace"), - VaultAuthMethod: ctx.String("vault-auth-method"), - VaultAuthNamespace: ctx.String("vault-auth-namespace"), - VaultMountPath: ctx.String("vault-mount-path"), - VaultRootToken: ctx.String("vault-root-token"), - VaultRoleID: ctx.String("vault-role-id"), - VaultRoleSecret: ctx.String("vault-role-secret"), - VaultServerCert: ctx.String("vault-server-cert"), - VaultClientCert: ctx.String("vault-client-cert"), - VaultClientCertKey: ctx.String("vault-client-cert-key"), - Version: Version, - Build: Build, - BuildTime: BuildTime, + RootUserAccess: gwcli.RootUserAccess, + RootUserSecret: gwcli.RootUserSecret, + Ports: ports, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + CertFile: certFile, + KeyFile: keyFile, + Debug: debug, + Quiet: quiet || ctx.Bool("quiet"), + KeepAlive: keepAlive, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: ctx.String("dir"), + VaultEndpointURL: ctx.String("vault-endpoint-url"), + VaultNamespace: ctx.String("vault-namespace"), + VaultSecretStoragePath: ctx.String("vault-secret-storage-path"), + VaultSecretStorageNamespace: ctx.String("vault-secret-storage-namespace"), + VaultAuthMethod: ctx.String("vault-auth-method"), + VaultAuthNamespace: ctx.String("vault-auth-namespace"), + VaultMountPath: ctx.String("vault-mount-path"), + VaultRootToken: ctx.String("vault-root-token"), + VaultRoleID: ctx.String("vault-role-id"), + VaultRoleSecret: ctx.String("vault-role-secret"), + VaultServerCert: ctx.String("vault-server-cert"), + VaultClientCert: ctx.String("vault-client-cert"), + VaultClientCertKey: ctx.String("vault-client-cert-key"), + DisableOIDCThumbprintAutoFetch: ctx.Bool("disable-oidc-thumbprint-autofetch"), + Version: Version, + Build: Build, + BuildTime: BuildTime, }) } diff --git a/embedgw/iam.go b/embedgw/iam.go index 959217c7..f6f087ef 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -120,6 +120,13 @@ type IAMConfig struct { Version string Build string BuildTime string + + // DisableOIDCThumbprintAutoFetch disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback for when ThumbprintList is omitted. When set, + // an omitted ThumbprintList is rejected instead of the IAM API making an + // outbound TLS connection to the caller-supplied URL — for restricted + // or air-gapped deployments. + DisableOIDCThumbprintAutoFetch bool } var iamAPIRunning atomic.Bool @@ -198,6 +205,9 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.Quiet { opts = append(opts, iamapi.WithQuiet()) } + if cfg.DisableOIDCThumbprintAutoFetch { + opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) + } if cfg.Debug { debuglogger.SetDebugEnabled() } diff --git a/iamapi/controller.go b/iamapi/controller.go index 0cd44868..0fc3a5a3 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -30,10 +30,19 @@ import ( type IAMApiController struct { store storage.Storer + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback when ThumbprintList is omitted (operational + // safety valve for restricted/air-gapped deployments); set via + // iamapi.WithOIDCThumbprintAutoFetchDisabled(). Defaults to false + // (auto-fetch enabled), matching real AWS behavior. + oidcThumbprintAutoFetchDisabled bool } -func NewController(store storage.Storer) IAMApiController { - return IAMApiController{store: store} +func NewController(store storage.Storer, oidcThumbprintAutoFetchDisabled bool) IAMApiController { + return IAMApiController{ + store: store, + oidcThumbprintAutoFetchDisabled: oidcThumbprintAutoFetchDisabled, + } } func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { @@ -848,3 +857,188 @@ func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + rawURL, ok := iamutil.RequestParam(ctx, "Url") + if !ok || rawURL == "" { + debuglogger.Logf("missing required CreateOpenIDConnectProvider parameter: Url") + return nil, iamerr.MissingValue("url") + } + url, err := iamutil.ValidateOIDCProviderURL(rawURL) + if err != nil { + return nil, err + } + + clientIDs := iamutil.ParseStringList(ctx, "ClientIDList") + if len(clientIDs) > storage.MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider) + } + for _, id := range clientIDs { + if len(id) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if len(thumbprints) == 0 { + if c.oidcThumbprintAutoFetchDisabled { + debuglogger.Logf("CreateOpenIDConnectProvider: ThumbprintList omitted and auto-fetch is disabled") + return nil, iamerr.MissingValue("thumbprintList") + } + fetched, err := iamutil.FetchThumbprint(ctx.Context(), url) + if err != nil { + debuglogger.Logf("failed to auto-fetch OIDC thumbprint for url %q: %v", url, err) + return nil, err + } + thumbprints = []string{fetched} + } else { + if err := iamutil.ValidateThumbprintList(thumbprints, false); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + provider := types.OIDCProvider{ + Arn: iamutil.BuildOIDCProviderArn(iamutil.DefaultAccountID, url), + Url: url, + ClientIDList: clientIDs, + ThumbprintList: thumbprints, + CreateDate: time.Now().UTC().Truncate(time.Second), + Tags: tags, + } + + stored, err := c.store.CreateOIDCProvider(ctx.Context(), provider) + if err != nil { + debuglogger.Logf("failed to create IAM OIDC provider for url %q: %v", url, err) + return nil, err + } + + return &Response{Data: &types.CreateOpenIDConnectProviderResponse{ + Result: types.CreateOpenIDConnectProviderResult{ + OpenIDConnectProviderArn: stored.Arn, + Tags: stored.Tags, + }, + }}, nil +} + +func (c IAMApiController) GetOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "GetOpenIDConnectProvider") + if err != nil { + return nil, err + } + + provider, err := c.store.GetOIDCProvider(ctx.Context(), arn) + if err != nil { + debuglogger.Logf("failed to get IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.GetOpenIDConnectProviderResponse{ + Result: types.GetOpenIDConnectProviderResult{ + Url: provider.Url, + ClientIDList: provider.ClientIDList, + ThumbprintList: provider.ThumbprintList, + CreateDate: provider.CreateDate, + Tags: provider.Tags, + }, + }}, nil +} + +func (c IAMApiController) ListOpenIDConnectProviders(ctx fiber.Ctx) (*Response, error) { + out, err := c.store.ListOIDCProviders(ctx.Context()) + if err != nil { + debuglogger.Logf("failed to list IAM OIDC providers: %v", err) + return nil, err + } + + return &Response{Data: &types.ListOpenIDConnectProvidersResponse{ + Result: types.ListOpenIDConnectProvidersResult{ + OpenIDConnectProviderList: types.OpenIDConnectProviderList{Members: out.Providers}, + }, + }}, nil +} + +func (c IAMApiController) DeleteOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "DeleteOpenIDConnectProvider") + if err != nil { + return nil, err + } + + if err := c.store.DeleteOIDCProvider(ctx.Context(), arn); err != nil { + debuglogger.Logf("failed to delete IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.DeleteOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) AddClientIDToOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "AddClientIDToOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required AddClientIDToOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.AddClientIDToOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to add client id %q to IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.AddClientIDToOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) RemoveClientIDFromOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "RemoveClientIDFromOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required RemoveClientIDFromOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.RemoveClientIDFromOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to remove client id %q from IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.RemoveClientIDFromOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "UpdateOpenIDConnectProviderThumbprint") + if err != nil { + return nil, err + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if err := iamutil.ValidateThumbprintList(thumbprints, true); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + + if err := c.store.UpdateOIDCProviderThumbprint(ctx.Context(), arn, thumbprints); err != nil { + debuglogger.Logf("failed to update IAM OIDC provider thumbprint for %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 3c0cefa8..0f80d9d1 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -18,6 +18,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "testing" "time" @@ -1671,6 +1672,335 @@ func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") } +func TestIAMApiControllerOIDCProviderLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ClientIDList.member.1": {"sts.amazonaws.com"}, + "ThumbprintList.member.1": {"6938FD4D98BAB03FAADB97B34396831E3780AEA1"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateOpenIDConnectProviderResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateOpenIDConnectProviderResponse" { + t.Fatalf("CreateOpenIDConnectProvider XMLName = %#v", createOut.XMLName) + } + wantArn := "arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com" + if createOut.Result.OpenIDConnectProviderArn != wantArn { + t.Fatalf("OpenIDConnectProviderArn = %q, want %q", createOut.Result.OpenIDConnectProviderArn, wantArn) + } + if len(createOut.Result.Tags) != 1 || createOut.Result.Tags[0].Key != "env" || createOut.Result.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", createOut.Result.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateOpenIDConnectProvider missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ThumbprintList.member.1": {"6938fd4d98bab03faadb97b34396831e3780aea1"}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", + "Provider with url https://token.actions.githubusercontent.com already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetOpenIDConnectProvider status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.Url != "token.actions.githubusercontent.com" { + t.Fatalf("Url = %q, want scheme stripped", getOut.Result.Url) + } + if len(getOut.Result.ClientIDList) != 1 || getOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList = %#v", getOut.Result.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(getOut.Result.ThumbprintList) != 1 || getOut.Result.ThumbprintList[0] != "6938fd4d98bab03faadb97b34396831e3780aea1" { + t.Fatalf("ThumbprintList = %#v, want lowercased", getOut.Result.ThumbprintList) + } + if getOut.Result.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + + list := doIAMAction(t, server, url.Values{"Action": {"ListOpenIDConnectProviders"}}) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListOpenIDConnectProviders status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListOpenIDConnectProvidersResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.OpenIDConnectProviderList.Members) != 1 || listOut.Result.OpenIDConnectProviderList.Members[0].Arn != wantArn { + t.Fatalf("ListOpenIDConnectProviders = %#v, want [%s]", listOut.Result.OpenIDConnectProviderList.Members, wantArn) + } + + addClientID := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addClientID.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider status = %d, body=%s", addClientID.StatusCode, readBody(t, addClientID)) + } + + // Idempotent: adding an already-present client ID succeeds silently. + addDuplicate := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addDuplicate.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider (duplicate) status = %d, body=%s", addDuplicate.StatusCode, readBody(t, addDuplicate)) + } + + removeClientID := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if removeClientID.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider status = %d, body=%s", removeClientID.StatusCode, readBody(t, removeClientID)) + } + + // Idempotent: removing an absent client ID succeeds silently. + removeAbsent := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"never-existed"}, + }) + if removeAbsent.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider (absent) status = %d, body=%s", removeAbsent.StatusCode, readBody(t, removeAbsent)) + } + + getAfterClientIDChanges := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterClientIDOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterClientIDChanges), &getAfterClientIDOut) + if len(getAfterClientIDOut.Result.ClientIDList) != 1 || getAfterClientIDOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList after add+remove = %#v, want [sts.amazonaws.com]", getAfterClientIDOut.Result.ClientIDList) + } + + updateThumbprint := doIAMAction(t, server, url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "OpenIDConnectProviderArn": {wantArn}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "ThumbprintList.member.2": {strings.Repeat("B", 40)}, + }) + if updateThumbprint.StatusCode != http.StatusOK { + t.Fatalf("UpdateOpenIDConnectProviderThumbprint status = %d, body=%s", updateThumbprint.StatusCode, readBody(t, updateThumbprint)) + } + + getAfterThumbprintUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterThumbprintOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterThumbprintUpdate), &getAfterThumbprintOut) + wantThumbprints := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) { + t.Fatalf("ThumbprintList after update = %#v, want %#v (full replace, lowercased)", getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteOpenIDConnectProvider status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + + // DeleteOpenIDConnectProvider is NOT idempotent, contradicting AWS's own + // published docs - a second delete of the same ARN must fail. + deleteAgain := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, deleteAgain, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenId connect Provider "+wantArn+" cannot be found.") + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenIDConnect Provider not found for arn "+wantArn) +} + +func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing url", + params: url.Values{"Action": {"CreateOpenIDConnectProvider"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must not be null", + }, + { + name: "no scheme at all", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"example.com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "Invalid Open ID Connect Provider URL", + }, + { + name: "wrong scheme", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"http://example.com"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL. The URL must begin with https://.", + }, + { + name: "query params", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com?foo=1"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "explicit port", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com:8443"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "url too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://" + strings.Repeat("a", 250) + ".com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "client id too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ClientIDList.member.1": {strings.Repeat("c", 256)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'clientID' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "thumbprint wrong length", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 39)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint must be exactly 40 characters.", + }, + { + name: "thumbprint too many", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("1", 40)}, + "ThumbprintList.member.2": {strings.Repeat("2", 40)}, + "ThumbprintList.member.3": {strings.Repeat("3", 40)}, + "ThumbprintList.member.4": {strings.Repeat("4", 40)}, + "ThumbprintList.member.5": {strings.Repeat("5", 40)}, + "ThumbprintList.member.6": {strings.Repeat("6", 40)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint list must contain fewer than 5 entries.", + }, + { + name: "duplicate tag keys", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "Tags.member.1.Key": {"key"}, + "Tags.member.1.Value": {"one"}, + "Tags.member.2.Key": {"KEY"}, + "Tags.member.2.Value": {"two"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + } + + 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 TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) { + 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), WithOIDCThumbprintAutoFetchDisabled()) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must not be null") +} + +// TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard confirms the +// auto-fetch fallback's SSRF guard is wired all the way through the HTTP +// action handler: an omitted ThumbprintList against a loopback URL must be +// rejected before any real network attempt, deterministically and without +// requiring outbound network access from the test environment. +func TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://127.0.0.1"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "OpenIdIdpCommunicationError", + "Could not connect to https://127.0.0.1") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 1df3877e..14d24010 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -425,6 +425,10 @@ func ValueTooLong(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 ValueTooShort(field string, minLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length greater than or equal to %d", field, minLength)) +} + func InvalidCharset(field string) Error { return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) } @@ -461,6 +465,47 @@ func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Erro return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) } +func EntityAlreadyExistsOIDCProvider(url string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Provider with url %s already exists.", url), http.StatusConflict) +} + +func NoSuchEntityOIDCProviderGet(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenIDConnect Provider not found for arn %s", arn), http.StatusNotFound) +} + +func NoSuchEntityOIDCProviderDelete(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenId connect Provider %s cannot be found.", arn), http.StatusNotFound) +} + +// AccessDeniedOIDCProvider is returned when a well-formed OIDC provider ARN +// references an account id other than callerAccountID. +func AccessDeniedOIDCProvider(callerAccountID, resourceArn string) Error { + return newSenderError("AccessDenied", fmt.Sprintf( + "User: arn:aws:iam::%s:root is not authorized to perform this action on resource: %s", + callerAccountID, resourceArn, + ), http.StatusForbidden) +} + +func ClientIdsPerOpenIdConnectProviderLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: %d", max), http.StatusConflict) +} + +func ThumbprintListTooLong(max int) Error { + return newSenderError("InvalidInput", fmt.Sprintf("Thumbprint list must contain fewer than %d entries.", max), http.StatusBadRequest) +} + +func ThumbprintListEmpty() Error { + return newSenderError("InvalidInput", "Thumbprint list must contain at least one entry.", http.StatusBadRequest) +} + +func OIDCProvidersPerAccountLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for OpenIDConnectProvidersPerAccount: %d", max), http.StatusConflict) +} + +func OpenIdIdpCommunicationError(url string) Error { + return newSenderError("OpenIdIdpCommunicationError", fmt.Sprintf("Could not connect to %s", url), http.StatusBadRequest) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/oidc.go b/iamapi/internal/iamutil/oidc.go new file mode 100644 index 00000000..36f9e365 --- /dev/null +++ b/iamapi/internal/iamutil/oidc.go @@ -0,0 +1,225 @@ +// 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 ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const ( + MinOIDCProviderArnLen = 20 + MaxOIDCProviderArnLen = 2048 + MaxOIDCProviderURLLen = 255 + MaxOIDCClientIDLen = 255 + MaxThumbprintsPerOIDCProvider = 5 + OIDCThumbprintLen = 40 + + oidcProviderResourceType = "oidc-provider" +) + +var oidcHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + +// ParseStringList reads flat indexed list members ".member.1", +// ".member.2", ... — the AWS Query-protocol wire form for a bare +// []string (distinct from ParseTags's Key/Value-pair member form, used by +// ClientIDList/ThumbprintList) — stopping at the first missing index. +// Returns nil if no entries are present. +func ParseStringList(ctx fiber.Ctx, paramName string) []string { + var values []string + for i := 1; ; i++ { + value, ok := RequestParam(ctx, fmt.Sprintf("%s.member.%d", paramName, i)) + if !ok { + break + } + values = append(values, value) + } + return values +} + +// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity +// provider. url must already have its "https://" scheme stripped. +func BuildOIDCProviderArn(accountID, url string) string { + return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, url) +} + +// ParseOIDCProviderArn validates arn's overall length and structural shape +// (arn:aws:iam:::/) and, on success, +// returns the resource segment — the provider's Url with "https://" already +// stripped, exactly as stored. The account-id segment must match +// DefaultAccountID; any other value is rejected with AccessDenied, matching +// real AWS's behavior for a well-formed ARN referencing a foreign account. +// +// Beyond the length and account-id checks, real AWS produces several more +// specific messages for structurally-malformed ARNs this function does not +// reproduce byte-for-byte — e.g. "Invalid service in ARN" for a non-iam +// service segment (a check this function does not perform at all), and a +// bare "Invalid ARN" (no echoed value) for a present-but-empty resource — +// this function falls back to a generic "Invalid ARN: %s" for those cases +// instead. +func ParseOIDCProviderArn(arn string) (string, error) { + if len(arn) < MinOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooShort("openIDConnectProviderArn", MinOIDCProviderArnLen) + } + if len(arn) > MaxOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooLong("openIDConnectProviderArn", MaxOIDCProviderArnLen) + } + + const prefix = "arn:aws:iam::" + if !strings.HasPrefix(arn, prefix) { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + + rest := strings.SplitN(arn[len(prefix):], ":", 2) + if len(rest) != 2 || rest[0] == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if rest[0] != DefaultAccountID { + debuglogger.Logf("OpenIDConnectProviderArn account id mismatch: %q", arn) + return "", iamerr.AccessDeniedOIDCProvider(DefaultAccountID, arn) + } + + resourceType, resource, ok := strings.Cut(rest[1], "/") + if !ok || resource == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if resourceType != oidcProviderResourceType { + debuglogger.Logf("wrong resource type in ARN: %q", arn) + return "", iamerr.ValidationError("Invalid resource type in ARN") + } + + return resource, nil +} + +// GetOIDCProviderArn resolves the OpenIDConnectProviderArn request +// parameter, validates its shape via ParseOIDCProviderArn, and returns the +// ARN exactly as supplied by the caller (used verbatim in NoSuchEntity +// messages, which echo the full ARN, not just the url). A missing +// parameter is rejected with iamerr.MissingValue — every OIDC action +// taking this parameter reports it identically. +func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) { + arn, ok := RequestParam(ctx, "OpenIDConnectProviderArn") + if !ok || arn == "" { + debuglogger.Logf("missing required %s parameter: OpenIDConnectProviderArn", operation) + return "", iamerr.MissingValue("openIDConnectProviderArn") + } + if _, err := ParseOIDCProviderArn(arn); err != nil { + return "", err + } + return arn, nil +} + +// ValidateOIDCProviderURL validates the Url parameter of +// CreateOpenIDConnectProvider and returns it with its "https://" scheme +// stripped (the canonical form used for ARN construction, storage keys, and +// GetOpenIDConnectProvider's own Url response field). +// +// This implements a pragmatic subset of AWS's real validation: scheme must +// be exactly "https", no userinfo/port/query/fragment, host must be a +// syntactically plausible RFC-1123-ish hostname or IP literal, overall +// length <= MaxOIDCProviderURLLen. It does not attempt to reproduce every +// hostname-shape check AWS performs; it returns clear InvalidInput/ +// ValidationError messages instead of chasing every malformed edge case. +func ValidateOIDCProviderURL(rawURL string) (string, error) { + if rawURL == "" { + return "", iamerr.MissingValue("url") + } + if len(rawURL) > MaxOIDCProviderURLLen { + return "", iamerr.ValueTooLong("url", MaxOIDCProviderURLLen) + } + // A URL with no scheme delimiter at all (e.g. "example.com") is + // rejected as ValidationError; one with a scheme other than https + // (e.g. "http://example.com") is rejected as InvalidInput — distinct + // error codes for distinct malformed inputs. + if !strings.Contains(rawURL, "://") { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if !strings.HasPrefix(rawURL, "https://") { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.") + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Port() != "" { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + if !isValidOIDCHostname(parsed.Hostname()) { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + + return strings.TrimPrefix(rawURL, "https://"), nil +} + +func isValidOIDCHostname(host string) bool { + if net.ParseIP(host) != nil { + return true + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if !oidcHostLabelPattern.MatchString(label) { + return false + } + } + return true +} + +// ValidateThumbprintList validates a parsed ThumbprintList: at most +// MaxThumbprintsPerOIDCProvider entries, each exactly OIDCThumbprintLen +// characters (no hex-charset check — any 40-char string is accepted). If +// required is true, an empty list is rejected +// (UpdateOpenIDConnectProviderThumbprint, no auto-fetch fallback exists +// there); if false, an empty list passes through untouched +// (CreateOpenIDConnectProvider, whose caller handles empty via auto-fetch +// before calling this). +func ValidateThumbprintList(thumbprints []string, required bool) error { + if required && len(thumbprints) == 0 { + return iamerr.ThumbprintListEmpty() + } + if len(thumbprints) > MaxThumbprintsPerOIDCProvider { + return iamerr.ThumbprintListTooLong(MaxThumbprintsPerOIDCProvider) + } + for _, tp := range thumbprints { + if len(tp) != OIDCThumbprintLen { + return iamerr.InvalidInput(fmt.Sprintf("Thumbprint must be exactly %d characters.", OIDCThumbprintLen)) + } + } + return nil +} + +// NormalizeThumbprintList lowercases every entry: AWS stores/returns +// thumbprints lowercased regardless of submitted case. +func NormalizeThumbprintList(thumbprints []string) []string { + out := make([]string, len(thumbprints)) + for i, tp := range thumbprints { + out[i] = strings.ToLower(tp) + } + return out +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint.go b/iamapi/internal/iamutil/oidc_thumbprint.go new file mode 100644 index 00000000..11ff9881 --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -0,0 +1,127 @@ +// 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 ( + "context" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "net" + "strings" + "time" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const oidcThumbprintFetchTimeout = 8 * time.Second + +// FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch +// behavior: it opens a raw TLS handshake (crypto/tls, not a full +// HTTP GET) to host:443, where host is derived from providerURL (a +// scheme-stripped OIDC provider Url), and returns the SHA-1 thumbprint of +// the last (top-most/intermediate CA) certificate in the peer's presented +// chain. +// +// SSRF hardening (mandatory): the hostname is resolved once via +// net.DefaultResolver.LookupIP; if any resolved address is +// loopback/private/link-local/unspecified/multicast (this range covers +// 169.254.169.254 and other cloud metadata endpoints), the fetch is +// rejected before any connection attempt. The TLS dial then targets one of +// the pre-validated IPs directly (never re-resolving the hostname at dial +// time, closing the DNS-rebinding TOCTOU gap) while presenting the original +// hostname via tls.Config.ServerName for SNI/certificate purposes. +// +// tls.Config.InsecureSkipVerify is deliberately set: this handshake exists +// solely to observe whatever certificate chain the peer presents — that is +// the entire point of AWS's thumbprint-pinning feature (trusting an +// operator-established fingerprint for IDPs whose certs may not pass +// standard verification). No application data is sent or received over +// this connection, so skipping chain verification does not expose any real +// traffic to a MITM. +func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { + host := hostFromOIDCUrl(providerURL) + displayURL := "https://" + providerURL + + ctx, cancel := context.WithTimeout(ctx, oidcThumbprintFetchTimeout) + defer cancel() + + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + debuglogger.Logf("oidc thumbprint fetch: dns lookup failed for %q: %v", host, err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + for _, ip := range ips { + if isDisallowedFetchTarget(ip) { + debuglogger.Logf("oidc thumbprint fetch: refusing to dial disallowed address %q for host %q", ip, host) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + } + + dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), "443")) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: tls dial failed for %q (%s): %v", host, ips[0], err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + + thumbprint, err := ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: %v", err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + return thumbprint, nil +} + +// ThumbprintFromChain computes AWS's documented OIDC thumbprint: the SHA-1 +// hash of the DER bytes of the last (top-most/intermediate CA) certificate +// in chain, hex-encoded and lowercased. Split out from FetchThumbprint as a +// pure function specifically so it is unit-testable (e.g. against a chain +// obtained from httptest.NewTLSServer) without going through +// FetchThumbprint's SSRF guard, which must always reject loopback targets +// and therefore can never itself be exercised against a same-process test +// server. +func ThumbprintFromChain(chain []*x509.Certificate) (string, error) { + if len(chain) == 0 { + return "", errors.New("iamutil: empty certificate chain") + } + top := chain[len(chain)-1] + sum := sha1.Sum(top.Raw) + return hex.EncodeToString(sum[:]), nil +} + +func isDisallowedFetchTarget(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() +} + +// hostFromOIDCUrl extracts the host (no scheme, no path — OIDC provider +// URLs are validated to disallow explicit ports) from a scheme-stripped +// provider Url. +func hostFromOIDCUrl(providerURL string) string { + if before, _, ok := strings.Cut(providerURL, "/"); ok { + return before + } + return providerURL +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint_test.go b/iamapi/internal/iamutil/oidc_thumbprint_test.go new file mode 100644 index 00000000..39d65a9c --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -0,0 +1,122 @@ +// 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 ( + "context" + "crypto/sha1" + "crypto/tls" + "encoding/hex" + "net" + "net/http/httptest" + "testing" +) + +// TestThumbprintFromChain exercises the pure cert-chain-hashing logic +// (AWS's OIDC thumbprint is the SHA-1 hash of the DER bytes of the +// last/top-most certificate in the peer's presented chain, hex encoded and +// lowercased) against a real TLS handshake with a locally generated +// self-signed certificate. +// +// This deliberately dials httptest.NewTLSServer directly with tls.Dial +// rather than going through FetchThumbprint, whose SSRF guard must always +// reject loopback targets — exactly what a local test server is. +func TestThumbprintFromChain(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + conn, err := tls.Dial("tcp", srv.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + + chain := conn.ConnectionState().PeerCertificates + if len(chain) == 0 { + t.Fatal("expected at least one peer certificate") + } + + got, err := ThumbprintFromChain(chain) + if err != nil { + t.Fatalf("ThumbprintFromChain: %v", err) + } + + sum := sha1.Sum(chain[len(chain)-1].Raw) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Fatalf("ThumbprintFromChain = %q, want %q", got, want) + } + if len(got) != OIDCThumbprintLen { + t.Fatalf("thumbprint length = %d, want %d", len(got), OIDCThumbprintLen) + } +} + +func TestThumbprintFromChainEmptyChain(t *testing.T) { + if _, err := ThumbprintFromChain(nil); err == nil { + t.Fatal("expected error for empty certificate chain") + } +} + +// TestFetchThumbprintSSRFGuard confirms FetchThumbprint refuses to dial +// loopback/private targets before any network attempt, matching the +// mandatory SSRF hardening design: 127.0.0.1 is exactly the kind of +// address a malicious CreateOpenIDConnectProvider caller could supply to +// probe the gateway's own local network. +func TestFetchThumbprintSSRFGuard(t *testing.T) { + tests := []string{ + "127.0.0.1", + "169.254.169.254", // cloud metadata endpoint + "::1", + } + for _, host := range tests { + t.Run(host, func(t *testing.T) { + _, err := FetchThumbprint(context.Background(), host) + if err == nil { + t.Fatalf("FetchThumbprint(%q): expected SSRF guard error, got nil", host) + } + }) + } +} + +func TestFetchThumbprintDNSFailure(t *testing.T) { + _, err := FetchThumbprint(context.Background(), "this-host-should-not-resolve.invalid") + if err == nil { + t.Fatal("expected error for unresolvable host") + } +} + +func TestIsDisallowedFetchTarget(t *testing.T) { + tests := []struct { + ip string + disallowed bool + }{ + {"127.0.0.1", true}, + {"169.254.169.254", true}, + {"10.0.0.5", true}, + {"192.168.1.1", true}, + {"::1", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + } + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("invalid test IP %q", tt.ip) + } + if got := isDisallowedFetchTarget(ip); got != tt.disallowed { + t.Errorf("isDisallowedFetchTarget(%q) = %v, want %v", tt.ip, got, tt.disallowed) + } + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ddb39bd2..430398fb 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -38,41 +38,51 @@ type IAMApiRouter struct { Ctrl IAMApiController actions map[string]ActionHandler rootCreds *RootCredentials + // oidcThumbprintAutoFetchDisabled is threaded into the controller; + // see IAMApiController.oidcThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func (r *IAMApiRouter) Init() { - ctrl := NewController(r.store) - r.Ctrl = ctrl + r.Ctrl = NewController(r.store, r.oidcThumbprintAutoFetchDisabled) r.actions = map[string]ActionHandler{ // User CRUD - "CreateUser": ctrl.CreateUser, - "DeleteUser": ctrl.DeleteUser, - "GetUser": ctrl.GetUser, - "ListUsers": ctrl.ListUsers, - "UpdateUser": ctrl.UpdateUser, + "CreateUser": r.Ctrl.CreateUser, + "DeleteUser": r.Ctrl.DeleteUser, + "GetUser": r.Ctrl.GetUser, + "ListUsers": r.Ctrl.ListUsers, + "UpdateUser": r.Ctrl.UpdateUser, // User Access Key CRUD - "CreateAccessKey": ctrl.CreateAccessKey, - "UpdateAccessKey": ctrl.UpdateAccessKey, - "DeleteAccessKey": ctrl.DeleteAccessKey, - "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, - "ListAccessKeys": ctrl.ListAccessKeys, + "CreateAccessKey": r.Ctrl.CreateAccessKey, + "UpdateAccessKey": r.Ctrl.UpdateAccessKey, + "DeleteAccessKey": r.Ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": r.Ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": r.Ctrl.ListAccessKeys, // User Inline Policy CRUD - "PutUserPolicy": ctrl.PutUserPolicy, - "GetUserPolicy": ctrl.GetUserPolicy, - "DeleteUserPolicy": ctrl.DeleteUserPolicy, - "ListUserPolicies": ctrl.ListUserPolicies, + "PutUserPolicy": r.Ctrl.PutUserPolicy, + "GetUserPolicy": r.Ctrl.GetUserPolicy, + "DeleteUserPolicy": r.Ctrl.DeleteUserPolicy, + "ListUserPolicies": r.Ctrl.ListUserPolicies, // Role CRUD - "CreateRole": ctrl.CreateRole, - "GetRole": ctrl.GetRole, - "ListRoles": ctrl.ListRoles, - "DeleteRole": ctrl.DeleteRole, - "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + "CreateRole": r.Ctrl.CreateRole, + "GetRole": r.Ctrl.GetRole, + "ListRoles": r.Ctrl.ListRoles, + "DeleteRole": r.Ctrl.DeleteRole, + "UpdateAssumeRolePolicy": r.Ctrl.UpdateAssumeRolePolicy, // Role Inline Policy CRUD - "PutRolePolicy": ctrl.PutRolePolicy, - "GetRolePolicy": ctrl.GetRolePolicy, - "DeleteRolePolicy": ctrl.DeleteRolePolicy, - "ListRolePolicies": ctrl.ListRolePolicies, + "PutRolePolicy": r.Ctrl.PutRolePolicy, + "GetRolePolicy": r.Ctrl.GetRolePolicy, + "DeleteRolePolicy": r.Ctrl.DeleteRolePolicy, + "ListRolePolicies": r.Ctrl.ListRolePolicies, + // OIDC Provider CRUD + "CreateOpenIDConnectProvider": r.Ctrl.CreateOpenIDConnectProvider, + "GetOpenIDConnectProvider": r.Ctrl.GetOpenIDConnectProvider, + "ListOpenIDConnectProviders": r.Ctrl.ListOpenIDConnectProviders, + "DeleteOpenIDConnectProvider": r.Ctrl.DeleteOpenIDConnectProvider, + "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, + "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, + "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/server.go b/iamapi/server.go index c5ccbe47..43660610 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -58,6 +58,9 @@ type IAMApiServer struct { maxRequests int socketPerm os.FileMode onListen func() + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback; see WithOIDCThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { @@ -89,6 +92,7 @@ func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { server.app = app server.Router.app = app server.Router.rootCreds = server.rootCreds + server.Router.oidcThumbprintAutoFetchDisabled = server.oidcThumbprintAutoFetchDisabled app.Use("*", recover.New(recover.Config{ EnableStackTrace: true, @@ -161,6 +165,15 @@ func WithRootUserCreds(root RootCredentials) Option { } } +// WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's +// TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an +// omitted ThumbprintList is rejected with a MissingValue error instead of +// the gateway making an outbound TLS connection to the caller-supplied URL +// — an operational safety valve for restricted/air-gapped deployments. +func WithOIDCThumbprintAutoFetchDisabled() Option { + return func(s *IAMApiServer) { s.oidcThumbprintAutoFetchDisabled = true } +} + func (s *IAMApiServer) ServeMultiPort(ports []string) error { if len(ports) == 0 { return fmt.Errorf("no ports specified") diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 536418d8..70cc7ac1 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -24,6 +24,7 @@ import ( "time" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" "github.com/versity/versitygw/internal/iamstore" ) @@ -63,6 +64,11 @@ type iamConfig struct { Roles map[string]types.Role `json:"roles"` // RoleNameIndex is UserNameIndex's counterpart for roles. RoleNameIndex map[string]string `json:"roleNameIndex"` + + // OIDCProviders is keyed directly by the provider's Url (scheme + // stripped, exactly as given at creation — no index needed since + // lookup is by exact string, not a case-insensitive human name). + OIDCProviders map[string]types.OIDCProvider `json:"oidcProviders"` } func defaultIAMConfig() iamConfig { @@ -72,6 +78,7 @@ func defaultIAMConfig() iamConfig { UserNameIndex: map[string]string{}, Roles: map[string]types.Role{}, RoleNameIndex: map[string]string{}, + OIDCProviders: map[string]types.OIDCProvider{}, } } @@ -104,6 +111,10 @@ func normalizeIAMConfig(conf *iamConfig) { conf.RoleNameIndex[key] = name } } + + if conf.OIDCProviders == nil { + conf.OIDCProviders = make(map[string]types.OIDCProvider) + } } // lookupUser resolves name to the canonical stored user name and entry, @@ -983,3 +994,182 @@ func cloneRole(role types.Role) *types.Role { cloned.Policies.Inline = slices.Clone(role.Policies.Inline) return &cloned } + +func (s *InternalStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, 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.OIDCProviders[provider.Url]; ok { + return nil, iamerr.EntityAlreadyExistsOIDCProvider("https://" + provider.Url) + } + if len(conf.OIDCProviders) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + + conf.OIDCProviders[provider.Url] = provider + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + s.RLock() + defer s.RUnlock() + + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(conf.OIDCProviders)) + for _, p := range conf.OIDCProviders { + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: p.Arn}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *InternalStore) DeleteOIDCProvider(_ context.Context, arn 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 + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + if _, ok := conf.OIDCProviders[url]; !ok { + return nil, iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + delete(conf.OIDCProviders, url) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) AddClientIDToOIDCProvider(_ context.Context, arn, clientID 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 + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + if slices.Contains(provider.ClientIDList, clientID) { + return json.Marshal(conf) + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) RemoveClientIDFromOIDCProvider(_ context.Context, arn, clientID 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 + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return json.Marshal(conf) + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn string, thumbprints []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 + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + provider.ThumbprintList = thumbprints + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func cloneOIDCProvider(p types.OIDCProvider) *types.OIDCProvider { + cloned := p + cloned.ClientIDList = slices.Clone(p.ClientIDList) + cloned.ThumbprintList = slices.Clone(p.ThumbprintList) + cloned.Tags = slices.Clone(p.Tags) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index aa915c19..7f0b9a73 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -37,6 +37,14 @@ const MaxInlinePolicyBytesPerUser = 2048 // all of a single IAM role's inline policy documents combined const MaxInlinePolicyBytesPerRole = 10240 +// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single +// OIDC provider may hold at once +const MaxClientIDsPerOIDCProvider = 100 + +// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a +// single account may hold +const MaxOIDCProvidersPerAccount = 100 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -148,6 +156,10 @@ type ListRolePoliciesOutput struct { Marker string } +type ListOIDCProvidersOutput struct { + Providers []types.OpenIDConnectProviderListEntry +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -177,6 +189,15 @@ type Storer interface { GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) + + // OIDC Provider CRUD + CreateOIDCProvider(ctx context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + ListOIDCProviders(ctx context.Context) (*ListOIDCProvidersOutput, error) + DeleteOIDCProvider(ctx context.Context, arn string) error + AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error + RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error + UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c914d85..5b9d9694 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -16,6 +16,7 @@ package storage import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( 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/internal/iamutil" "github.com/versity/versitygw/iamapi/types" ) @@ -1119,6 +1121,296 @@ func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { return role, nil } +// oidcProvidersPath is the KV prefix under which OIDC providers are stored, +// kept distinct from secretStoragePath/rolesPath. +func (s *VaultStore) oidcProvidersPath() string { + return s.secretStoragePath + "/oidc-providers" +} + +// oidcProviderPathSegment returns the literal KV path segment for a +// provider identified by its scheme-stripped url. OIDC provider URLs may +// themselves contain "/" (e.g. "host/" and "host/path" are distinct valid +// providers) and Vault KV paths treat "/" as a path +// separator, so — unlike RoleName/UserName, which never contain "/" and are +// used as literal path segments directly — the raw url cannot safely be +// used as a KV path segment. base64url-encoding (RawURLEncoding: lossless, +// produces only [A-Za-z0-9_-], no "/" or "=" padding) collapses it to one +// opaque, path-safe segment. The same segment is reused as the single outer +// JSON key inside the KV secret body (a deliberate deviation from +// roleToVaultMap/userToVaultMap's convention of keying on the +// human-readable name — simpler here since only one identifier needs to be +// tracked for read-back, not two). +func oidcProviderPathSegment(url string) string { + return base64.RawURLEncoding.EncodeToString([]byte(url)) +} + +func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) { + segment := oidcProviderPathSegment(provider.Url) + path := s.oidcProvidersPath() + "/" + segment + displayURL := "https://" + provider.Url + + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, err + } + } + if resp != nil { + if slices.Contains(resp.Data.Keys, segment) { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if len(resp.Data.Keys) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + } + + providerMap, err := oidcProviderToVaultMap(provider) + if err != nil { + return nil, fmt.Errorf("serialize oidc provider: %w", err) + } + req := schema.KvV2WriteRequest{ + Data: map[string]any{segment: providerMap}, + 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.EntityAlreadyExistsOIDCProvider(displayURL) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, 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.EntityAlreadyExistsOIDCProvider(displayURL) + } + 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 cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + segment := oidcProviderPathSegment(url) + path := s.oidcProvidersPath() + "/" + segment + + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + 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.NoSuchEntityOIDCProviderGet(arn) + } + return nil, err + } + } + + provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) + if err != nil { + return nil, err + } + return cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + return nil, err + } + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(resp.Data.Keys)) + for _, segment := range resp.Data.Keys { + // Read each secret by its already-known key rather than decoding + // segment back to a url, populating the list from each secret's own + // stored fields. + path := s.oidcProvidersPath() + "/" + segment + secretResp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + secretResp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + return nil, err + } + } + provider, err := parseVaultOIDCProvider(secretResp.Data.Data, segment) + if err != nil { + return nil, err + } + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: provider.Arn}) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *VaultStore) DeleteOIDCProvider(_ context.Context, arn string) error { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return err + } + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + + // Existence check first: unlike deleteRoleByPath (only reached after + // DeleteRole's own prior GetRole existence check), Delete's own + // not-found path is load-bearing here (NOT idempotent). + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + return err + } + } + + return s.deleteOIDCProviderByURL(url) +} + +func (s *VaultStore) deleteOIDCProviderByURL(url string) error { + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + _, 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 +} + +// AddClientIDToOIDCProvider / RemoveClientIDFromOIDCProvider / +// UpdateOIDCProviderThumbprint use non-atomic get-then-replace, mirroring +// the existing consistency model of UpdateAssumeRolePolicy/PutRolePolicy's +// Vault implementations — this codebase has no CAS-protected +// read-modify-write for Vault mutations today, and this does not introduce +// one. + +func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + if slices.Contains(provider.ClientIDList, clientID) { + return nil + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return nil + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + provider.ThumbprintList = thumbprints + return s.replaceOIDCProvider(ctx, *provider) +} + +// replaceOIDCProvider overwrites the stored document for provider.Url by +// deleting all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider) error { + if err := s.deleteOIDCProviderByURL(provider.Url); err != nil { + return err + } + _, err := s.CreateOIDCProvider(ctx, provider) + return err +} + +var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine") + +func oidcProviderToVaultMap(provider types.OIDCProvider) (map[string]any, error) { + b, err := json.Marshal(provider) + 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 +} + +// parseVaultOIDCProvider reconstructs an OIDCProvider from the raw +// map[string]any vault returns. The outer key is the base64url path +// segment used at write time (oidcProviderPathSegment), not a +// human-readable value — unlike parseVaultRole/parseVaultUser. +func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProvider, error) { + raw, ok := data[segment] + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + providerMap, ok := raw.(map[string]any) + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + b, err := json.Marshal(providerMap) + if err != nil { + return types.OIDCProvider{}, fmt.Errorf("re-marshal vault oidc provider: %w", err) + } + var provider types.OIDCProvider + if err := json.Unmarshal(b, &provider); err != nil { + return types.OIDCProvider{}, fmt.Errorf("unmarshal vault oidc provider: %w", err) + } + return provider, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/oidc.go b/iamapi/types/oidc.go new file mode 100644 index 00000000..d8ee2e84 --- /dev/null +++ b/iamapi/types/oidc.go @@ -0,0 +1,129 @@ +// 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" +) + +// OIDCProvider is the storage-layer representation of an IAM OIDC identity +// provider. Unlike Role, it is never marshaled to XML directly — each real +// IAM action returns a different subset of its fields — so it is copied +// field-by-field into the narrower XML result types +type OIDCProvider struct { + // Arn is the full arn:aws:iam:::oidc-provider/ ARN. + Arn string `json:"arn"` + // Url is stored WITHOUT the "https://" scheme prefix. This is both the + // ARN's resource-path suffix and the exact string + // GetOpenIDConnectProvider echoes back in its own Url field. It is never + // case-folded or otherwise normalized + Url string `json:"url"` + ClientIDList []string `json:"clientIDList,omitempty"` + ThumbprintList []string `json:"thumbprintList,omitempty"` + CreateDate time.Time `json:"createDate"` + Tags []Tag `json:"tags,omitempty"` +} + +type CreateOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"` + Result CreateOpenIDConnectProviderResult `xml:"CreateOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateOpenIDConnectProviderResult struct { + OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type GetOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"` + Result GetOpenIDConnectProviderResult `xml:"GetOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetOpenIDConnectProviderResult struct { + Url string `xml:",omitempty"` + ClientIDList []string `xml:"ClientIDList>member,omitempty"` + ThumbprintList []string `xml:"ThumbprintList>member,omitempty"` + CreateDate time.Time `xml:"CreateDate"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type ListOpenIDConnectProvidersResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"` + Result ListOpenIDConnectProvidersResult `xml:"ListOpenIDConnectProvidersResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListOpenIDConnectProvidersResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListOpenIDConnectProvidersResult struct { + OpenIDConnectProviderList OpenIDConnectProviderList +} + +type OpenIDConnectProviderList struct { + Members []OpenIDConnectProviderListEntry `xml:"member"` +} + +type OpenIDConnectProviderListEntry struct { + Arn string `xml:"Arn"` +} + +type DeleteOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type AddClientIDToOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *AddClientIDToOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type RemoveClientIDFromOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *RemoveClientIDFromOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateOpenIDConnectProviderThumbprintResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateOpenIDConnectProviderThumbprintResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 9d618d75..8f380187 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1386,6 +1386,70 @@ func TestIAMListRolePolicies(ts *TestState) { ts.Run(IAMListRolePolicies_pagination) } +func TestIAMCreateOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMCreateOpenIDConnectProvider_missing_url) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_url) + ts.Run(IAMCreateOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMCreateOpenIDConnectProvider_too_many_client_ids) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_thumbprint) + ts.Run(IAMCreateOpenIDConnectProvider_duplicate_tag_keys) + ts.Run(IAMCreateOpenIDConnectProvider_already_exists) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error) + ts.Run(IAMCreateOpenIDConnectProvider_quota_exceeded) + ts.Run(IAMCreateOpenIDConnectProvider_success) + ts.Run(IAMCreateOpenIDConnectProvider_defaults) + ts.Run(IAMCreateOpenIDConnectProvider_ip_literal_host) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_edge_cases) + ts.Run(IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity) +} + +func TestIAMGetOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMGetOpenIDConnectProvider_missing_arn) + ts.Run(IAMGetOpenIDConnectProvider_invalid_arn) + ts.Run(IAMGetOpenIDConnectProvider_non_existing) + ts.Run(IAMGetOpenIDConnectProvider_success) +} + +func TestIAMListOpenIDConnectProviders(ts *TestState) { + ts.Run(IAMListOpenIDConnectProviders_success) +} + +func TestIAMDeleteOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMDeleteOpenIDConnectProvider_missing_arn) + ts.Run(IAMDeleteOpenIDConnectProvider_non_existing) + ts.Run(IAMDeleteOpenIDConnectProvider_success) + ts.Run(IAMDeleteOpenIDConnectProvider_not_idempotent) +} + +func TestIAMAddClientIDToOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_arn) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_client_id) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_limit_exceeded) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_success) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate) +} + +func TestIAMRemoveClientIDFromOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_success) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent) +} + +func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_arn) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_success) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1412,6 +1476,13 @@ func TestIAM(ts *TestState) { TestIAMGetRolePolicy(ts) TestIAMDeleteRolePolicy(ts) TestIAMListRolePolicies(ts) + TestIAMCreateOpenIDConnectProvider(ts) + TestIAMGetOpenIDConnectProvider(ts) + TestIAMListOpenIDConnectProviders(ts) + TestIAMDeleteOpenIDConnectProvider(ts) + TestIAMAddClientIDToOpenIDConnectProvider(ts) + TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) + TestIAMUpdateOpenIDConnectProviderThumbprint(ts) } func TestAccessControl(ts *TestState) { @@ -1957,6 +2028,49 @@ func GetIntTests() IntTests { "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, "IAMListRolePolicies_success": IAMListRolePolicies_success, "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, + "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, + "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, + "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, + "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, + "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, + "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, + "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, + "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, + "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, + "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, + "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, + "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, + "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, + "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, + "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, + "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, + "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, + "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, + "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, + "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, + "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, + "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, + "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, + "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, + "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, + "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, + "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, + "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, + "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, + "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, + "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, + "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, + "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, + "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, + "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, + "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, + "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, + "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, + "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, "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_add_client_id_to_oidc_provider.go b/tests/integration/iam_add_client_id_to_oidc_provider.go new file mode 100644 index 00000000..8f6bd2e5 --- /dev/null +++ b/tests/integration/iam_add_client_id_to_oidc_provider.go @@ -0,0 +1,204 @@ +// 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" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMAddClientIDToOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(addClientIDToOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_limit_exceeded(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := checkIAMApiErr( + addClientIDToOIDCProvider(client, arn, "one-too-many"), + iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate confirms +// adding an already-present client ID succeeds silently rather than +// erroring or creating a duplicate entry. +func IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com] (no duplicate), instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func addClientIDToOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.AddClientIDToOpenIDConnectProvider(ctx, &iam.AddClientIDToOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_create_oidc_provider.go b/tests/integration/iam_create_oidc_provider.go new file mode 100644 index 00000000..66832078 --- /dev/null +++ b/tests/integration/iam_create_oidc_provider.go @@ -0,0 +1,481 @@ +// 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" + "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" + "github.com/versity/versitygw/iamapi/storage" +) + +// validOIDCThumbprint is a syntactically valid (40 hex chars) thumbprint +// used whenever a test needs a ThumbprintList entry but isn't specifically +// exercising thumbprint validation. +const validOIDCThumbprint = "6938fd4d98bab03faadb97b34396831e3780aea1" + +func IAMCreateOpenIDConnectProvider_missing_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_missing_url" + body := []byte(url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("url")) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_url" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range []struct { + name string + url string + want iamerr.Error + }{ + {"no_scheme", "example.com", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"wrong_scheme", "http://example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.")}, + {"empty_host", "https://", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"userinfo", "https://user:pass@example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"query_params", "https://example.com?foo=1", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"fragment", "https://example.com#frag", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"explicit_port", "https://example.com:8443", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"invalid_hostname_chars", "https://exa_mple.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"too_long", "https://" + strings.Repeat("a", 250) + ".com", iamerr.ValueTooLong("url", 255)}, + } { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(tt.url)}) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{strings.Repeat("c", 256)}, + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("clientID", 255)) + }) +} + +func IAMCreateOpenIDConnectProvider_too_many_client_ids(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_too_many_client_ids" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider+1) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(err, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider)) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_thumbprint(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("a", 39)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.InvalidInput("Thumbprint must be exactly 40 characters.")); checkErr != nil { + return fmt.Errorf("wrong_length: %w", checkErr) + } + + _, err = createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.ThumbprintListTooLong(5)); checkErr != nil { + return fmt.Errorf("too_many: %w", checkErr) + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + 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 IAMCreateOpenIDConnectProvider_already_exists(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_already_exists" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + arn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + + _, dupErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + checkErr := checkIAMApiErr(dupErr, iamerr.EntityAlreadyExistsOIDCProvider(providerURL)) + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// confirms the network-dependent auto-fetch fallback (triggered by +// omitting ThumbprintList) is wired all the way through the real HTTP +// action handler: a loopback URL is rejected by the fetch's mandatory +// SSRF guard before any real network attempt, deterministically and +// without requiring outbound network access from the test environment. +func IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://127.0.0.1"), + }) + return checkIAMApiErr(err, iamerr.OpenIdIdpCommunicationError("https://127.0.0.1")) + }) +} + +// IAMCreateOpenIDConnectProvider_quota_exceeded tops the account up to +// storage.MaxOIDCProvidersPerAccount from whatever baseline count already +// exists, then confirms one more Create is rejected. It only ever creates +// (and cleans up) providers relative to the observed baseline, so it +// tolerates a non-empty account, but — like any test of a truly +// account-global, unscoped quota — it assumes no other test is +// concurrently creating/deleting OIDC providers, which holds for this +// suite's default sequential execution (not necessarily under --parallel). +func IAMCreateOpenIDConnectProvider_quota_exceeded(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_quota_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + baseline, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + + var created []string + defer func() { + for _, arn := range created { + if deleteErr := deleteOIDCProvider(client, arn); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM OIDC provider %q: %w", arn, deleteErr)) + } + } + }() + + for i := len(baseline.OpenIDConnectProviderList); i < storage.MaxOIDCProvidersPerAccount; i++ { + arn, createErr := createTestOIDCProvider(client) + if createErr != nil { + return fmt.Errorf("topping up to quota: %w", createErr) + } + created = append(created, arn) + } + + _, overErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(overErr, iamerr.OIDCProvidersPerAccountLimitExceeded(storage.MaxOIDCProvidersPerAccount)) + }) +} + +func IAMCreateOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com"}, + ThumbprintList: []string{strings.ToUpper(validOIDCThumbprint)}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := func() error { + wantArn := oidcProviderArn(providerURL) + if aws.ToString(out.OpenIDConnectProviderArn) != wantArn { + return fmt.Errorf("expected OpenIDConnectProviderArn %q, instead got %q", wantArn, aws.ToString(out.OpenIDConnectProviderArn)) + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected create output tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected CreateOpenIDConnectProvider response request id") + } + + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(get.Url) != wantURL { + return fmt.Errorf("expected Url %q (scheme stripped), instead got %q", wantURL, aws.ToString(get.Url)) + } + if len(get.ClientIDList) != 1 || get.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", get.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(get.ThumbprintList) != 1 || get.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s] (lowercased), instead got %#v", validOIDCThumbprint, get.ThumbprintList) + } + if get.CreateDate == nil || get.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateOpenIDConnectProvider_defaults(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + + checkErr := func() error { + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags in create output, instead got %#v", out.Tags) + } + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + if len(get.ClientIDList) != 0 { + return fmt.Errorf("expected no client ids, instead got %#v", get.ClientIDList) + } + if len(get.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %#v", get.Tags) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_ip_literal_host confirms an IP-literal +// host is accepted by exercising isValidOIDCHostname's net.ParseIP branch +// end-to-end. +func IAMCreateOpenIDConnectProvider_ip_literal_host(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_ip_literal_host" + return iamActionHandler(s, testName, func(client *iam.Client) error { + host := newIAMOIDCProviderIPHost() + arn, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + + get, getErr := getIAMOIDCProvider(client, arn) + checkErr := getErr + if getErr == nil && aws.ToString(get.Url) != host { + checkErr = fmt.Errorf("expected Url %q, instead got %q", host, aws.ToString(get.Url)) + } + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_edge_cases exercises two +// success-path ThumbprintList edge cases in one pass: exactly +// MaxThumbprintsPerOIDCProvider entries (the limit message says "fewer +// than 5", but 5 itself is accepted), and a 40-character entry outside the +// hex charset (AWS does not check for a hex charset). +func IAMCreateOpenIDConnectProvider_thumbprint_edge_cases(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases" + return iamActionHandler(s, testName, func(client *iam.Client) error { + checkThumbprints := func(thumbprints []string) error { + arn, err := createOIDCProviderReturningArn(client, thumbprints) + if err != nil { + return err + } + return deleteOIDCProvider(client, arn) + } + + if err := checkThumbprints([]string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + }); err != nil { + return fmt.Errorf("max_thumbprints_boundary: %w", err) + } + + if err := checkThumbprints([]string{strings.Repeat("z", 40)}); err != nil { + return fmt.Errorf("non_hex_thumbprint: %w", err) + } + return nil + }) +} + +// IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity confirms +// that a trailing slash is part of a provider's identity: "https://host" +// and "https://host/" register as two distinct providers, not a +// collision. +func IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + host := "oidc-test-" + genRandString(16) + ".example.com" + withoutSlash, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withoutSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + withSlash, err := createTestOIDCProviderWithURL(client, "https://"+host+"/") + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + if withoutSlash == withSlash { + return fmt.Errorf("expected distinct ARNs for %q and %q, both got %q", host, host+"/", withoutSlash) + } + return nil + }) +} + +// newIAMOIDCProviderURL returns a fresh https:// URL for a throwaway OIDC +// provider. Provider identity is the URL itself (there is no separate +// name), so genRandString's collision-free counter is what keeps +// concurrent/repeated test runs from colliding with each other or with any +// provider left over from a prior run. +func newIAMOIDCProviderURL() string { + return "https://oidc-test-" + genRandString(16) + ".example.com" +} + +// newIAMOIDCProviderIPHost returns a host string within the TEST-NET-2 +// documentation range (RFC 5737, 198.51.100.0/24 — never publicly +// routable), used to exercise CreateOpenIDConnectProvider's IP-literal +// hostname path without depending on any real, reachable host. +func newIAMOIDCProviderIPHost() string { + suffix := genRandString(1) + return fmt.Sprintf("198.51.100.%d", int(suffix[0])%254+1) +} + +func createOIDCProvider(client *iam.Client, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateOpenIDConnectProvider(ctx, input) +} + +// createTestOIDCProvider creates a provider at a fresh random URL with a +// single explicit valid thumbprint (bypassing the network-dependent +// auto-fetch path) and returns its ARN. +func createTestOIDCProvider(client *iam.Client) (string, error) { + return createTestOIDCProviderWithURL(client, newIAMOIDCProviderURL()) +} + +func createTestOIDCProviderWithURL(client *iam.Client, providerURL string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} + +func deleteOIDCProvider(client *iam.Client, arn string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteOpenIDConnectProvider(ctx, &iam.DeleteOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) + return err +} + +// oidcProviderArn builds the expected ARN for a provider created at +// providerURL, mirroring iamutil.BuildOIDCProviderArn without importing an +// internal package from this external test tree. +func oidcProviderArn(providerURL string) string { + return "arn:aws:iam::000000000000:oidc-provider/" + strings.TrimPrefix(providerURL, "https://") +} + +func createOIDCProviderReturningArn(client *iam.Client, thumbprints []string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: thumbprints, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} diff --git a/tests/integration/iam_delete_oidc_provider.go b/tests/integration/iam_delete_oidc_provider.go new file mode 100644 index 00000000..47526ece --- /dev/null +++ b/tests/integration/iam_delete_oidc_provider.go @@ -0,0 +1,84 @@ +// 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 ( + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_missing_arn" + body := []byte("Action=DeleteOpenIDConnectProvider&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.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMDeleteOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +func IAMDeleteOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + _, err = getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +// IAMDeleteOpenIDConnectProvider_not_idempotent confirms a second delete +// of the same ARN fails. +func IAMDeleteOpenIDConnectProvider_not_idempotent(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_not_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + err = deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} diff --git a/tests/integration/iam_get_oidc_provider.go b/tests/integration/iam_get_oidc_provider.go new file mode 100644 index 00000000..22469f2e --- /dev/null +++ b/tests/integration/iam_get_oidc_provider.go @@ -0,0 +1,143 @@ +// 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" + "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 IAMGetOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_missing_arn" + body := []byte("Action=GetOpenIDConnectProvider&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.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMGetOpenIDConnectProvider_invalid_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := getIAMOIDCProvider(client, tt.arn) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMGetOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMGetOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + created, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + arn := aws.ToString(created.OpenIDConnectProviderArn) + + checkErr := func() error { + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(out.Url) != wantURL { + return fmt.Errorf("expected Url %q, instead got %q", wantURL, aws.ToString(out.Url)) + } + wantClientIDs := []string{"sts.amazonaws.com", "another-client"} + if len(out.ClientIDList) != len(wantClientIDs) { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + for i, id := range wantClientIDs { + if out.ClientIDList[i] != id { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + } + if len(out.ThumbprintList) != 1 || out.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s], instead got %#v", validOIDCThumbprint, out.ThumbprintList) + } + if out.CreateDate == nil || out.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetOpenIDConnectProvider response request id") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMOIDCProvider(client *iam.Client, arn string) (*iam.GetOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetOpenIDConnectProvider(ctx, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) +} diff --git a/tests/integration/iam_list_oidc_providers.go b/tests/integration/iam_list_oidc_providers.go new file mode 100644 index 00000000..b5d2b5b3 --- /dev/null +++ b/tests/integration/iam_list_oidc_providers.go @@ -0,0 +1,122 @@ +// 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" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +func IAMListOpenIDConnectProviders_success(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviders_success" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + before, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(before.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListOpenIDConnectProviders response request id") + } + baseline := oidcProviderArnSet(before) + + arnA, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + arnB, err := createTestOIDCProvider(client) + if err != nil { + delErr := deleteOIDCProvider(client, arnA) + return errors.Join(err, delErr) + } + + cleanup := func(arns ...string) error { + var errs error + for _, arn := range arns { + if delErr := deleteOIDCProvider(client, arn); delErr != nil { + errs = errors.Join(errs, delErr) + } + } + return errs + } + + afterCreate, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnA, arnB)) + } + createdSet := oidcProviderArnSet(afterCreate) + if _, ok := createdSet[arnA]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnA), cleanup(arnA, arnB)) + } + if _, ok := createdSet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnB), cleanup(arnA, arnB)) + } + for arn := range baseline { + if _, ok := createdSet[arn]; !ok { + return errors.Join(fmt.Errorf("expected pre-existing %q to still be listed", arn), cleanup(arnA, arnB)) + } + } + + if err := deleteOIDCProvider(client, arnA); err != nil { + return errors.Join(err, cleanup(arnB)) + } + + afterDeleteA, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnB)) + } + afterDeleteASet := oidcProviderArnSet(afterDeleteA) + if _, ok := afterDeleteASet[arnA]; ok { + return errors.Join(fmt.Errorf("expected %q to be absent after delete", arnA), cleanup(arnB)) + } + if _, ok := afterDeleteASet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q still listed", arnB), cleanup(arnB)) + } + + if err := deleteOIDCProvider(client, arnB); err != nil { + return err + } + + afterDeleteB, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if _, ok := oidcProviderArnSet(afterDeleteB)[arnB]; ok { + return fmt.Errorf("expected %q to be absent after delete", arnB) + } + + return nil + }) +} + +func listIAMOIDCProviders(client *iam.Client) (*iam.ListOpenIDConnectProvidersOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListOpenIDConnectProviders(ctx, &iam.ListOpenIDConnectProvidersInput{}) +} + +func oidcProviderArnSet(out *iam.ListOpenIDConnectProvidersOutput) map[string]struct{} { + set := make(map[string]struct{}, len(out.OpenIDConnectProviderList)) + for _, p := range out.OpenIDConnectProviderList { + if p.Arn != nil { + set[*p.Arn] = struct{}{} + } + } + return set +} diff --git a/tests/integration/iam_remove_client_id_from_oidc_provider.go b/tests/integration/iam_remove_client_id_from_oidc_provider.go new file mode 100644 index 00000000..fa214b1c --- /dev/null +++ b/tests/integration/iam_remove_client_id_from_oidc_provider.go @@ -0,0 +1,163 @@ +// 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" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(removeClientIDFromOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := func() error { + if err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + got, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(got.ClientIDList) != 1 || got.ClientIDList[0] != "another-client" { + return fmt.Errorf("expected ClientIDList [another-client], instead got %#v", got.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent confirms +// removing a client ID that was never added succeeds silently rather than +// erroring. +func IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := removeClientIDFromOIDCProvider(client, arn, "never-added") + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func removeClientIDFromOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.RemoveClientIDFromOpenIDConnectProvider(ctx, &iam.RemoveClientIDFromOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_update_oidc_provider_thumbprint.go b/tests/integration/iam_update_oidc_provider_thumbprint.go new file mode 100644 index 00000000..143e54ae --- /dev/null +++ b/tests/integration/iam_update_oidc_provider_thumbprint.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 integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_arn(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn" + body := []byte(url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "Version": {"2010-05-08"}, + "ThumbprintList.member.1": {validOIDCThumbprint}, + }.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 { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, []string{}), iamerr.ThumbprintListEmpty()) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40), + } + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, thumbprints), iamerr.ThumbprintListTooLong(5)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr( + updateOIDCProviderThumbprint(client, arn, []string{strings.Repeat("a", 39)}), + iamerr.InvalidInput("Thumbprint must be exactly 40 characters."), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := updateOIDCProviderThumbprint(client, arn, []string{validOIDCThumbprint}) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_success(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + newThumbprints := []string{strings.Repeat("A", 40), strings.Repeat("B", 40)} + if err := updateOIDCProviderThumbprint(client, arn, newThumbprints); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + // Full replace (the original validOIDCThumbprint must be gone), + // lowercased (submitted uppercase). + want := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(out.ThumbprintList, want) { + return fmt.Errorf("expected ThumbprintList %#v, instead got %#v", want, out.ThumbprintList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints +// confirms exactly MaxThumbprintsPerOIDCProvider entries succeeds — the +// limit message says "fewer than 5", but 5 itself is accepted. +func IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + } + checkErr := updateOIDCProviderThumbprint(client, arn, thumbprints) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateOIDCProviderThumbprint(client *iam.Client, arn string, thumbprints []string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.UpdateOpenIDConnectProviderThumbprint(ctx, &iam.UpdateOpenIDConnectProviderThumbprintInput{ + OpenIDConnectProviderArn: &arn, + ThumbprintList: thumbprints, + }) + return err +} From ab2b816633e965c5ce9a02d9ceb924225e79593a Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 5 Aug 2026 16:11:36 +0400 Subject: [PATCH 07/10] feat: add STS web identity federation, IAM policy Condition support, and access control enforcement Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size. Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions. Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control. Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth. Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities. --- chart/templates/deployment.yaml | 4 +- cmd/versitygw/gateway_test.go | 4 +- cmd/versitygw/iam.go | 7 +- cmd/versitygw/main.go | 34 +- cmd/versitygw/test.go | 13 +- cmd/vgwrdma/main.go | 11 +- debuglogger/level.go | 90 + debuglogger/level_test.go | 97 + debuglogger/logger.go | 66 +- debuglogger/redact.go | 135 + debuglogger/redact_test.go | 191 ++ debuglogger/xmlmask.go | 221 ++ debuglogger/xmlmask_test.go | 138 + embedgw/embedgw.go | 17 +- embedgw/iam.go | 11 +- extra/example.conf | 25 +- go.mod | 4 +- iamapi/authentication_test.go | 121 +- iamapi/authorization_test.go | 602 ++++ iamapi/controller.go | 283 +- iamapi/controller_test.go | 1036 +++++- iamapi/iamerr/errors.go | 85 + iamapi/internal/iammiddleware/auth.go | 269 +- iamapi/internal/iammiddleware/policy.go | 403 +++ iamapi/internal/iamutil/access_key.go | 40 + iamapi/internal/iamutil/oidc_thumbprint.go | 66 +- .../internal/iamutil/oidc_thumbprint_test.go | 54 +- iamapi/internal/iamutil/request_test.go | 51 + iamapi/internal/iamutil/user.go | 21 + iamapi/internal/iamutil/webidentity.go | 887 +++++ iamapi/internal/iamutil/webidentity_test.go | 587 ++++ iamapi/policy/condition.go | 500 +++ iamapi/policy/condition_test.go | 761 +++++ iamapi/policy/document.go | 101 +- iamapi/policy/document_test.go | 43 + iamapi/policy/identity.go | 150 + iamapi/policy/identity_test.go | 268 ++ iamapi/policy/trust.go | 219 +- iamapi/policy/trust_test.go | 68 +- iamapi/policy/validate.go | 10 +- iamapi/policy/validate_test.go | 14 +- iamapi/policy/webidentity.go | 303 ++ iamapi/policy/webidentity_test.go | 296 ++ iamapi/response.go | 16 + iamapi/router.go | 54 +- iamapi/server.go | 3 + iamapi/storage/internal.go | 135 + iamapi/storage/storer.go | 27 + iamapi/storage/storer_test.go | 191 +- iamapi/storage/vault.go | 1240 ++++--- iamapi/types/identity.go | 48 + iamapi/types/sts.go | 98 + internal/httpctx/context_keys.go | 1 + internal/sigv4auth/auth.go | 5 + internal/sigv4auth/compare.go | 33 + internal/sigv4auth/compare_test.go | 39 + internal/sigv4auth/query.go | 8 +- internal/sigv4auth/verify.go | 9 +- s3api/admin-server.go | 3 + s3api/server.go | 3 + tests/integration/group-tests.go | 2418 +++++++------- tests/integration/iam_access_control.go | 2843 +++++++++++++++++ .../iam_assume_role_with_web_identity.go | 687 ++++ tests/integration/iam_get_caller_identity.go | 176 + tests/integration/s3conf.go | 6 + 65 files changed, 14691 insertions(+), 1658 deletions(-) create mode 100644 debuglogger/level.go create mode 100644 debuglogger/level_test.go create mode 100644 debuglogger/redact.go create mode 100644 debuglogger/redact_test.go create mode 100644 debuglogger/xmlmask.go create mode 100644 debuglogger/xmlmask_test.go create mode 100644 iamapi/authorization_test.go create mode 100644 iamapi/internal/iammiddleware/policy.go create mode 100644 iamapi/internal/iamutil/webidentity.go create mode 100644 iamapi/internal/iamutil/webidentity_test.go create mode 100644 iamapi/policy/condition.go create mode 100644 iamapi/policy/condition_test.go create mode 100644 iamapi/policy/identity.go create mode 100644 iamapi/policy/identity_test.go create mode 100644 iamapi/policy/webidentity.go create mode 100644 iamapi/policy/webidentity_test.go create mode 100644 iamapi/types/identity.go create mode 100644 iamapi/types/sts.go create mode 100644 internal/sigv4auth/compare.go create mode 100644 internal/sigv4auth/compare_test.go create mode 100644 tests/integration/iam_access_control.go create mode 100644 tests/integration/iam_assume_role_with_web_identity.go create mode 100644 tests/integration/iam_get_caller_identity.go diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 91ab0946..393835a5 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -106,8 +106,8 @@ spec: value: "true" {{- end }} {{- if .Values.gateway.debug }} - - name: VGW_DEBUG - value: "true" + - name: VGW_LOG_LEVEL + value: "debug" {{- end }} {{- if .Values.gateway.accessLog }} - name: VGW_ACCESS_LOG diff --git a/cmd/versitygw/gateway_test.go b/cmd/versitygw/gateway_test.go index 9740342d..d17ebf7e 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -25,7 +25,7 @@ var ( func initEnv(dir string) { // both - debug = true + logLevel = "debug" region = "us-east-1" // server @@ -98,7 +98,7 @@ func TestIntegration(t *testing.T) { integration.WithRegion(region), integration.WithEndpoint(endpoint), } - if debug { + if logLevel != "silent" && logLevel != "" { opts = append(opts, integration.WithDebug()) } diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index 5b246165..fbd6fa54 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -33,6 +33,11 @@ func runIAM(ctx *cli.Context) error { }() } + logLvl, err := parseLogLevel() + if err != nil { + return err + } + return embedgw.RunIAMAPI(ctx.Context, &embedgw.IAMConfig{ RootUserAccess: gwcli.RootUserAccess, RootUserSecret: gwcli.RootUserSecret, @@ -41,7 +46,7 @@ func runIAM(ctx *cli.Context) error { MaxRequests: maxRequests, CertFile: certFile, KeyFile: keyFile, - Debug: debug, + LogLevel: logLvl, Quiet: quiet || ctx.Bool("quiet"), KeepAlive: keepAlive, HealthPath: healthPath, diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index f2e84869..2a04adcc 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -25,6 +25,7 @@ import ( "github.com/urfave/cli/v2" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/cmd/internal/gwcli" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/s3api/utils" ) @@ -48,6 +49,7 @@ var ( adminLogFile string healthPath string virtualDomain string + logLevel string debug bool keepAlive bool pprof string @@ -367,9 +369,19 @@ func initFlags() []cli.Flag { EnvVars: []string{"VGW_ADMIN_CERT_KEY"}, Destination: &admKeyFile, }, + &cli.StringFlag{ + Name: "log-level", + Usage: `debug logger verbosity: "silent" (default, no debug output), ` + + `"debug" (full request/response logging with secrets and tokens masked), or ` + + `"unsafe" (full logging with NO masking -- prints access keys, secrets, session ` + + `tokens, and signatures in the clear; only use for local troubleshooting, never in production)`, + Value: "silent", + EnvVars: []string{"VGW_LOG_LEVEL"}, + Destination: &logLevel, + }, &cli.BoolFlag{ Name: "debug", - Usage: "enable debug output", + Usage: "enable debug output (deprecated: use --log-level=debug for finer-grained control)", Value: false, EnvVars: []string{"VGW_DEBUG"}, Destination: &debug, @@ -808,6 +820,19 @@ func initFlags() []cli.Flag { } } +// parseLogLevel parses the --log-level flag value shared by the gateway and +// standalone IAM API commands. --debug is a deprecated alias for +// --log-level=debug, kept for backward compatibility. +func parseLogLevel() (debuglogger.Level, error) { + if debug { + fmt.Fprintf(os.Stderr, "WARNING: --debug is deprecated; use --log-level=debug for finer-grained control over debug logging\n") + if logLevel == "silent" { + return debuglogger.LevelDebug, nil + } + } + return debuglogger.ParseLevel(logLevel) +} + func runGateway(ctx context.Context, be backend.Backend) error { if pprof != "" { // Listen on the specified address for pprof debug endpoints. @@ -824,6 +849,11 @@ func runGateway(ctx context.Context, be backend.Backend) error { return fmt.Errorf("copy-object-threshold must be positive") } + logLvl, err := parseLogLevel() + if err != nil { + return err + } + return embedgw.RunVersityGW(ctx, be, &embedgw.Config{ RootUserAccess: gwcli.RootUserAccess, RootUserSecret: gwcli.RootUserSecret, @@ -840,7 +870,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { AdminCertFile: admCertFile, AdminKeyFile: admKeyFile, CORSAllowOrigin: corsAllowOrigin, - Debug: debug, + LogLevel: logLvl, IAMDebug: iamDebug, Quiet: quiet, Readonly: readonly, diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 7c01507d..7525fcfc 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -42,6 +42,7 @@ var ( checksumDisable bool versioningEnabled bool azureTests bool + testDebug bool tlsStatus bool parallel bool windowsTests bool @@ -91,7 +92,7 @@ func initTestFlags() []cli.Flag { Name: "debug", Usage: "enable debug mode", Aliases: []string{"d"}, - Destination: &debug, + Destination: &testDebug, }, &cli.BoolFlag{ Name: "allow-insecure", @@ -301,7 +302,7 @@ func initTestCommands() []*cli.Command { integration.WithPartSize(partSize), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if hostStyle { @@ -362,7 +363,7 @@ func initTestCommands() []*cli.Command { integration.WithConcurrency(concurrency), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if checksumDisable { @@ -409,7 +410,7 @@ func websiteHostingAction(ctx *cli.Context) error { if websitePortTest != "" { opts = append(opts, integration.WithWebsitePort(websitePortTest)) } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } @@ -435,7 +436,7 @@ func getAction(tf testFunc) func(ctx *cli.Context) error { integration.WithEndpoint(endpoint), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if versioningEnabled { @@ -485,7 +486,7 @@ func extractIntTests() (commands []*cli.Command) { integration.WithEndpoint(endpoint), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if versioningEnabled { diff --git a/cmd/vgwrdma/main.go b/cmd/vgwrdma/main.go index 00db4c25..03c344f0 100644 --- a/cmd/vgwrdma/main.go +++ b/cmd/vgwrdma/main.go @@ -29,6 +29,7 @@ import ( "github.com/versity/versitygw/cmd/internal/gwcli" "github.com/versity/versitygw/cubackend" "github.com/versity/versitygw/cumiddleware" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/rdma" "github.com/versity/versitygw/s3api" @@ -897,6 +898,14 @@ func initFlags() []cli.Flag { } } +// debugLogLevel translates the --debug flag into a debuglogger.Level. +func debugLogLevel() debuglogger.Level { + if debug { + return debuglogger.LevelDebug + } + return debuglogger.LevelSilent +} + func runGateway(ctx context.Context, be backend.Backend) error { if pprof != "" { // Listen on the specified address for pprof debug endpoints. @@ -976,7 +985,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { AdminCertFile: admCertFile, AdminKeyFile: admKeyFile, CORSAllowOrigin: corsAllowOrigin, - Debug: debug, + LogLevel: debugLogLevel(), IAMDebug: iamDebug, Quiet: quiet, Readonly: readonly, diff --git a/debuglogger/level.go b/debuglogger/level.go new file mode 100644 index 00000000..37b47f00 --- /dev/null +++ b/debuglogger/level.go @@ -0,0 +1,90 @@ +// 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 debuglogger + +import ( + "fmt" + "strings" + "sync/atomic" +) + +// Level controls both whether the debug logger produces any output and, +// when it does, whether secrets and tokens embedded in that output are +// masked. +type Level int32 + +const ( + // LevelSilent prints no debug logs. This is the default. + LevelSilent Level = iota + // LevelDebug prints full request/response logs with secrets and + // tokens (access keys, session tokens, signatures, ...) masked. + LevelDebug + // LevelUnsafe prints full request/response logs with secrets and + // tokens shown in the clear. Anyone with access to this output can + // read and replay credentials directly; never use in production. + LevelUnsafe +) + +func (l Level) String() string { + switch l { + case LevelSilent: + return "silent" + case LevelDebug: + return "debug" + case LevelUnsafe: + return "unsafe" + default: + return "unknown" + } +} + +// ParseLevel parses "silent", "debug", or "unsafe" (case-insensitive) into +// a Level. An empty string parses as LevelSilent. +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "silent": + return LevelSilent, nil + case "debug": + return LevelDebug, nil + case "unsafe": + return LevelUnsafe, nil + default: + return LevelSilent, fmt.Errorf("invalid log level %q: must be one of 'silent', 'debug', 'unsafe'", s) + } +} + +var currentLevel atomic.Int32 + +// SetLevel sets the active debug log level. +func SetLevel(l Level) { + currentLevel.Store(int32(l)) +} + +// CurrentLevel returns the active debug log level. +func CurrentLevel() Level { + return Level(currentLevel.Load()) +} + +// IsDebugEnabled returns true when the debug logger produces output, at +// either LevelDebug or LevelUnsafe. +func IsDebugEnabled() bool { + return CurrentLevel() != LevelSilent +} + +// IsUnsafeEnabled returns true when the debug logger is configured to print +// secrets and tokens without masking. +func IsUnsafeEnabled() bool { + return CurrentLevel() == LevelUnsafe +} diff --git a/debuglogger/level_test.go b/debuglogger/level_test.go new file mode 100644 index 00000000..c21e601c --- /dev/null +++ b/debuglogger/level_test.go @@ -0,0 +1,97 @@ +// 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 debuglogger + +import "testing" + +func TestParseLevel(t *testing.T) { + tests := []struct { + in string + want Level + wantErr bool + }{ + {"silent", LevelSilent, false}, + {"", LevelSilent, false}, + {"SILENT", LevelSilent, false}, + {"debug", LevelDebug, false}, + {" Debug ", LevelDebug, false}, + {"unsafe", LevelUnsafe, false}, + {"UNSAFE", LevelUnsafe, false}, + {"verbose", LevelSilent, true}, + {"true", LevelSilent, true}, + } + for _, tt := range tests { + got, err := ParseLevel(tt.in) + if (err != nil) != tt.wantErr { + t.Errorf("ParseLevel(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr) + continue + } + if err == nil && got != tt.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestLevelGatesDebugAndUnsafe(t *testing.T) { + defer SetLevel(LevelSilent) + + SetLevel(LevelSilent) + if IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelSilent = true, want false") + } + if IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelSilent = true, want false") + } + + SetLevel(LevelDebug) + if !IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelDebug = false, want true") + } + if IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelDebug = true, want false") + } + + SetLevel(LevelUnsafe) + if !IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelUnsafe = false, want true") + } + if !IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelUnsafe = false, want true") + } +} + +func TestIsIAMDebugEnabledRequiresBothLevelAndIAMFlag(t *testing.T) { + defer func() { + SetLevel(LevelSilent) + debugIAMEnabled.Store(false) + }() + + SetLevel(LevelSilent) + debugIAMEnabled.Store(true) + if IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with iam-debug set but level silent = true, want false") + } + + SetLevel(LevelDebug) + debugIAMEnabled.Store(false) + if IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with level debug but iam-debug unset = true, want false") + } + + debugIAMEnabled.Store(true) + if !IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with level debug and iam-debug set = false, want true") + } +} diff --git a/debuglogger/logger.go b/debuglogger/logger.go index 8e06d2b8..2ef2ed37 100644 --- a/debuglogger/logger.go +++ b/debuglogger/logger.go @@ -64,30 +64,46 @@ func printError(prefix prefix, er error) { // Logs http request details: headers, body, params, query args func LogFiberRequestDetails(ctx fiber.Ctx) { - // Log the full request url - fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.OriginalURL() + // Log the full request url, with sensitive query parameter values + // redacted (ctx.OriginalURL() would print them in the clear). + fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.Path() + if qs := debugRedactedQueryString(ctx.Request().URI().QueryArgs()); qs != "" { + fullURL += "?" + qs + } fmt.Printf("%s[URL]: %s%s\n", green, fullURL, reset) // log request headers wrapInBox(green, "REQUEST HEADERS", boxWidth, func() { for key, value := range ctx.Request().Header.All() { - printWrappedLine(yellow, string(key), string(value)) + printWrappedLine(yellow, string(key), debugRedact(string(key), string(value))) } }) // skip request body log for PutObject and UploadPart skipBodyLog := isLargeDataAction(ctx) if !skipBodyLog { - body := ctx.Request().Body() - if len(body) != 0 { + if postArgs := ctx.Request().PostArgs(); postArgs.Len() != 0 { + // form-encoded body (e.g. AWS Query protocol requests like + // IAM/STS): log key=value pairs so sensitive fields (e.g. + // WebIdentityToken) can be redacted individually, instead of + // printing the raw, still-encoded body bytes. printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false) - fmt.Printf("%s%s%s\n", blue, body, reset) + for key, value := range postArgs.All() { + fmt.Printf("%s%s=%s%s\n", blue, key, debugRedact(string(key), string(value)), reset) + } printHorizontalBorder(blue, boxWidth, false) + } else { + body := ctx.Request().Body() + if len(body) != 0 { + printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false) + fmt.Printf("%s%s%s\n", blue, formatBodyForLog(body), reset) + printHorizontalBorder(blue, boxWidth, false) + } } } if ctx.Request().URI().QueryArgs().Len() != 0 { for key, value := range ctx.Request().URI().QueryArgs().All() { - log.Printf("%s: %s", key, value) + log.Printf("%s: %s", key, debugRedact(string(key), string(value))) } } } @@ -96,7 +112,7 @@ func LogFiberRequestDetails(ctx fiber.Ctx) { func LogFiberResponseDetails(ctx fiber.Ctx) { wrapInBox(green, "RESPONSE HEADERS", boxWidth, func() { for key, value := range ctx.Response().Header.All() { - printWrappedLine(yellow, string(key), string(value)) + printWrappedLine(yellow, string(key), debugRedact(string(key), string(value))) } }) @@ -104,27 +120,26 @@ func LogFiberResponseDetails(ctx fiber.Ctx) { if !ok { body := ctx.Response().Body() if len(body) != 0 { - PrintInsideHorizontalBorders(blue, "RESPONSE BODY", string(body), boxWidth) + PrintInsideHorizontalBorders(blue, "RESPONSE BODY", formatBodyForLog(body), boxWidth) } } } -var debugEnabled atomic.Bool - -// SetDebugEnabled sets the debug mode -func SetDebugEnabled() { - debugEnabled.Store(true) -} - -// IsDebugEnabled returns true if debugging is enabled -func IsDebugEnabled() bool { - return debugEnabled.Load() +// formatBodyForLog returns body pretty-printed with property-level secret +// masking when it parses as XML (the case for every S3 and IAM API request +// or response body reaching this point), and the raw body unchanged +// otherwise. Masking is skipped entirely at LevelUnsafe. +func formatBodyForLog(body []byte) string { + if masked, ok := maskXMLBody(body); ok { + return string(masked) + } + return string(body) } // Logf is the same as 'fmt.Printf' with debug prefix, // a color added and '\n' at the end func Logf(format string, v ...any) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } @@ -133,7 +148,7 @@ func Logf(format string, v ...any) { // Infof prints out green info block with [INFO]: prefix func Infof(format string, v ...any) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } @@ -147,15 +162,16 @@ func SetIAMDebugEnabled() { debugIAMEnabled.Store(true) } -// IsDebugEnabled returns true if debugging enabled +// IsIAMDebugEnabled returns true if IAM subsystem debugging is enabled: the +// --iam-debug flag was set and the log level is not silent. func IsIAMDebugEnabled() bool { - return debugEnabled.Load() + return IsDebugEnabled() && debugIAMEnabled.Load() } // IAMLogf is the same as 'fmt.Printf' with debug prefix, // a color added and '\n' at the end func IAMLogf(format string, v ...any) { - if !debugIAMEnabled.Load() { + if !IsIAMDebugEnabled() { return } @@ -165,7 +181,7 @@ func IAMLogf(format string, v ...any) { // PrintInsideHorizontalBorders prints the text inside horizontal // border and title in the center of upper border func PrintInsideHorizontalBorders(color Color, title, text string, width int) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } printBoxTitleLine(color, title, width, false) diff --git a/debuglogger/redact.go b/debuglogger/redact.go new file mode 100644 index 00000000..a7fe4346 --- /dev/null +++ b/debuglogger/redact.go @@ -0,0 +1,135 @@ +// 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 debuglogger + +import ( + "net/url" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/valyala/fasthttp" +) + +// redactedValue replaces the value of a matched sensitive field entirely. +// The debug logger uses the same mask character for the partial masking +// applied to fields like AccessKeyId +const redactedValue = "****" + +// sensitiveFieldNames lists header, query, and form field names (matched +// case-insensitively) whose values are bearer credentials or raw key +// material rather than diagnostic data: a JWT, a session token, a request +// signature, or an SSE-C encryption key. Anyone with log access could +// replay or reuse a logged value directly, so these are replaced with +// redactedValue everywhere a request or response is logged, in both normal +// and debug-mode logging. +var sensitiveFieldNames = map[string]bool{ + "authorization": true, + "x-amz-security-token": true, + "webidentitytoken": true, + // The request signature itself: with the rest of a presigned URL + // (which is not otherwise secret) this is everything needed to replay + // the exact request until it expires. + "x-amz-signature": true, + // Carries the access key ID. Not secret on its own, but there's no + // diagnostic value in logging it that isn't already available from + // the (also masked) Authorization header, so mask it defensively too. + "x-amz-credential": true, + // SSE-C requests carry the raw AES-256 customer-provided encryption + // key in these headers. The paired "...-key-md5" headers are just a + // checksum of the key (not reversible to the key itself), so they're + // left unmasked to help correlate requests using the same key. + "x-amz-server-side-encryption-customer-key": true, + "x-amz-copy-source-server-side-encryption-customer-key": true, +} + +func isSensitiveFieldName(name string) bool { + return sensitiveFieldNames[strings.ToLower(name)] +} + +// redact returns redactedValue in place of value when key names a +// credential-bearing header, query, or form field. +func redact(key, value string) string { + if isSensitiveFieldName(key) { + return redactedValue + } + return value +} + +// RedactedQueryString rebuilds the request's query string with sensitive +// parameter values (see sensitiveFieldNames) replaced by redactedValue. It +// is safe to write to any log, including the default (non-debug) access +// log. +func RedactedQueryString(queryArgs *fasthttp.Args) string { + if queryArgs.Len() == 0 { + return "" + } + + var b strings.Builder + first := true + for key, value := range queryArgs.All() { + if !first { + b.WriteByte('&') + } + first = false + b.WriteString(url.QueryEscape(string(key))) + b.WriteByte('=') + b.WriteString(url.QueryEscape(redact(string(key), string(value)))) + } + return b.String() +} + +// RedactedQueryParamsTag is a logger.LogFunc that replaces the fiber logger +// middleware's built-in ${queryParams} tag with a redacted query string +// (see RedactedQueryString). Register it as a CustomTags override for +// logger.TagQueryStringParams so the default (non-debug) access log never +// writes credential-bearing query parameters such as WebIdentityToken or +// X-Amz-Security-Token. +var RedactedQueryParamsTag logger.LogFunc = func(output logger.Buffer, ctx fiber.Ctx, _ *logger.Data, _ string) (int, error) { + return output.WriteString(RedactedQueryString(ctx.Request().URI().QueryArgs())) +} + +// debugRedact is redact's counterpart for the debug logger's own +// header/query/form-field printing. Unlike redact (used by the always-on, +// non-debug access log), it honors LevelUnsafe: at that level it returns +// value unchanged so the debug output shows exactly what was on the wire. +// At LevelDebug it masks identically to redact. +func debugRedact(key, value string) string { + if IsUnsafeEnabled() { + return value + } + return redact(key, value) +} + +// debugRedactedQueryString is RedactedQueryString's counterpart for the +// debug logger, using debugRedact so LevelUnsafe shows unmasked values. +func debugRedactedQueryString(queryArgs *fasthttp.Args) string { + if queryArgs.Len() == 0 { + return "" + } + + var b strings.Builder + first := true + for key, value := range queryArgs.All() { + if !first { + b.WriteByte('&') + } + first = false + b.WriteString(url.QueryEscape(string(key))) + b.WriteByte('=') + b.WriteString(url.QueryEscape(debugRedact(string(key), string(value)))) + } + return b.String() +} diff --git a/debuglogger/redact_test.go b/debuglogger/redact_test.go new file mode 100644 index 00000000..1abe6af3 --- /dev/null +++ b/debuglogger/redact_test.go @@ -0,0 +1,191 @@ +// 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 debuglogger + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" +) + +func TestRedact(t *testing.T) { + tests := []struct { + name string + key string + value string + want string + }{ + {name: "Authorization header", key: "Authorization", value: "AWS4-HMAC-SHA256 ...", want: redactedValue}, + {name: "header name matched case-insensitively", key: "AUTHORIZATION", value: "secret", want: redactedValue}, + {name: "security token", key: "X-Amz-Security-Token", value: "secret", want: redactedValue}, + {name: "presigned request signature", key: "X-Amz-Signature", value: "deadbeef", want: redactedValue}, + {name: "presigned request signature matched case-insensitively", key: "x-amz-signature", value: "deadbeef", want: redactedValue}, + {name: "presigned request credential", key: "X-Amz-Credential", value: "AKIAEXAMPLE/20260101/us-east-1/s3/aws4_request", want: redactedValue}, + {name: "web identity token form/query field", key: "WebIdentityToken", value: "secret", want: redactedValue}, + {name: "SSE-C customer key header", key: "X-Amz-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue}, + {name: "SSE-C copy-source customer key header", key: "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue}, + {name: "SSE-C customer key MD5 untouched (checksum, not a secret)", key: "X-Amz-Server-Side-Encryption-Customer-Key-MD5", value: "deadbeef==", want: "deadbeef=="}, + {name: "unrelated header untouched", key: "Content-Type", value: "application/xml", want: "application/xml"}, + {name: "unrelated query param untouched", key: "Action", value: "AssumeRoleWithWebIdentity", want: "AssumeRoleWithWebIdentity"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := redact(tt.key, tt.value); got != tt.want { + t.Errorf("redact(%q, %q) = %q, want %q", tt.key, tt.value, got, tt.want) + } + }) + } +} + +func TestDebugRedactHonorsUnsafeLevel(t *testing.T) { + defer SetLevel(LevelSilent) + + SetLevel(LevelDebug) + if got := debugRedact("Authorization", "secret-sig"); got != redactedValue { + t.Errorf("debugRedact at LevelDebug = %q, want %q", got, redactedValue) + } + + SetLevel(LevelUnsafe) + if got := debugRedact("Authorization", "secret-sig"); got != "secret-sig" { + t.Errorf("debugRedact at LevelUnsafe = %q, want unmasked value", got) + } +} + +func TestRedactedQueryString(t *testing.T) { + args := &fasthttp.Args{} + args.Parse("Action=AssumeRoleWithWebIdentity&WebIdentityToken=super-secret-jwt") + + got := RedactedQueryString(args) + + if strings.Contains(got, "super-secret-jwt") { + t.Fatalf("RedactedQueryString leaked the token: %q", got) + } + if !strings.Contains(got, "Action=AssumeRoleWithWebIdentity") { + t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got) + } + if !strings.Contains(got, url.QueryEscape(redactedValue)) { + t.Errorf("RedactedQueryString missing redaction marker: %q", got) + } +} + +func TestRedactedQueryStringEmpty(t *testing.T) { + if got := RedactedQueryString(&fasthttp.Args{}); got != "" { + t.Errorf("RedactedQueryString(empty) = %q, want empty string", got) + } +} + +// TestRedactedQueryStringMasksPresignedCredentials asserts that a presigned +// request's X-Amz-Signature (and X-Amz-Credential) never reach the default +// access log, since together with the rest of the (non-secret) presigned URL +// they're everything needed to replay the exact signed request until it +// expires. +func TestRedactedQueryStringMasksPresignedCredentials(t *testing.T) { + args := &fasthttp.Args{} + args.Parse("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAEXAMPLE%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=deadbeefcafe") + + got := RedactedQueryString(args) + + for _, secret := range []string{"deadbeefcafe", "AKIAEXAMPLE"} { + if strings.Contains(got, secret) { + t.Fatalf("RedactedQueryString leaked presigned credential material %q: %q", secret, got) + } + } + if !strings.Contains(got, "X-Amz-Algorithm=AWS4-HMAC-SHA256") { + t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got) + } +} + +// TestLogFiberRequestAndResponseDetailsRedactSensitiveFields sends dummy +// secrets through the request header, query, and form-body paths (plus the +// response header path) and asserts that none of them appear in the debug +// logger's captured output, only the redaction marker in their place. This +// covers a GET AssumeRoleWithWebIdentity's WebIdentityToken query parameter, +// and, in debug mode, the Authorization and X-Amz-Security-Token headers. +func TestLogFiberRequestAndResponseDetailsRedactSensitiveFields(t *testing.T) { + const ( + dummyToken = "dummy-web-identity-jwt" + dummyAuth = "AWS4-HMAC-SHA256 Credential=AKIADUMMYEXAMPLE/..." + dummySecurity = "dummy-security-token" + ) + + app := fiber.New() + app.Post("/", func(ctx fiber.Ctx) error { + LogFiberRequestDetails(ctx) + ctx.Response().Header.Set("X-Amz-Security-Token", dummySecurity) + LogFiberResponseDetails(ctx) + return ctx.SendString("ok") + }) + + body := "Action=AssumeRoleWithWebIdentity&WebIdentityToken=" + dummyToken + req := httptest.NewRequest(http.MethodPost, "/?WebIdentityToken="+dummyToken, strings.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + req.Header.Set("Authorization", dummyAuth) + req.Header.Set("X-Amz-Security-Token", dummySecurity) + + output := captureLogOutput(t, func() { + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + }) + + for _, secret := range []string{dummyToken, dummyAuth, dummySecurity} { + if strings.Contains(output, secret) { + t.Errorf("captured debug output leaked secret %q:\n%s", secret, output) + } + } + if !strings.Contains(output, redactedValue) { + t.Errorf("expected redaction marker %q in captured output:\n%s", redactedValue, output) + } +} + +// captureLogOutput redirects both fmt.Printf (via os.Stdout, used by the +// box-drawing helpers) and the standard "log" package (used for the +// per-query-arg lines) into a buffer for the duration of fn. +func captureLogOutput(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + origStdout := os.Stdout + origLogOutput := log.Writer() + os.Stdout = w + log.SetOutput(w) + defer func() { + os.Stdout = origStdout + log.SetOutput(origLogOutput) + }() + + fn() + + w.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy: %v", err) + } + return buf.String() +} diff --git a/debuglogger/xmlmask.go b/debuglogger/xmlmask.go new file mode 100644 index 00000000..52b254e3 --- /dev/null +++ b/debuglogger/xmlmask.go @@ -0,0 +1,221 @@ +// 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 debuglogger + +import ( + "bytes" + "encoding/xml" + "fmt" + "strings" +) + +// accessKeyVisiblePrefixLen is the number of leading characters left +// visible when partially masking an access key ID (e.g. "AKIA" or "ASIA"), +// enough to identify the credential type without exposing the value. +const accessKeyVisiblePrefixLen = 4 + +// fullyMaskedXMLElements lists XML element (and attribute) local names +// whose text content is a usable credential. Every occurrence, at any +// nesting depth, is replaced with redactedValue when masking applies. +var fullyMaskedXMLElements = map[string]bool{ + "SecretAccessKey": true, + "SessionToken": true, + "WebIdentityToken": true, +} + +// partiallyMaskedXMLElements lists XML element (and attribute) local names +// whose value is not itself a bearer credential but is still worth +// partially hiding. Only a short identifying prefix is left visible; see +// maskPartial. +var partiallyMaskedXMLElements = map[string]bool{ + "AccessKeyId": true, +} + +// maskPartial reveals only the first accessKeyVisiblePrefixLen characters +// of value, replacing the rest with redactedValue. Values no longer than +// the visible prefix are masked in full, so short values are never fully +// exposed. +func maskPartial(value string) string { + if len(value) <= accessKeyVisiblePrefixLen { + return redactedValue + } + return value[:accessKeyVisiblePrefixLen] + redactedValue +} + +// maskXMLValue returns the masked form of an XML element or attribute +// named name with text content value, per fullyMaskedXMLElements and +// partiallyMaskedXMLElements. It returns value unchanged when name isn't +// sensitive, or when unsafe is true (LevelUnsafe: print everything as-is). +func maskXMLValue(name, value string, unsafe bool) string { + if unsafe { + return value + } + if fullyMaskedXMLElements[name] { + return redactedValue + } + if partiallyMaskedXMLElements[name] { + return maskPartial(value) + } + return value +} + +// xmlNode is an in-memory XML element tree, used so the pretty-printer can +// decide per element whether to inline its text content or nest its +// children, and can mask leaf text without disturbing surrounding +// structure, namespaces, or attributes. +type xmlNode struct { + name string + space string // namespace URI; only rendered at the root + attrs []xml.Attr + text string + children []*xmlNode +} + +// maskXMLBody parses body as XML, and returns a pretty-printed copy with +// sensitive element and attribute values masked (per maskXMLValue), and ok +// true. If body is not well-formed XML, it returns (nil, false) and the +// caller should fall back to printing the raw bytes. +// +// The parse-then-render round trip preserves the full document structure +// (namespace, nesting, attributes) exactly, since every element still +// carries its original name, namespace, attributes, and children; only leaf +// text content matching a sensitive field name is replaced. +func maskXMLBody(body []byte) ([]byte, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '<' { + return nil, false + } + + dec := xml.NewDecoder(bytes.NewReader(body)) + root, xmlDecl, err := parseXMLTree(dec) + if err != nil { + return nil, false + } + + var out bytes.Buffer + if xmlDecl != "" { + out.WriteString(xmlDecl) + out.WriteByte('\n') + } + renderXMLNode(&out, root, 0, IsUnsafeEnabled()) + return out.Bytes(), true +} + +// parseXMLTree reads tokens from dec up to and including the document's +// single root element, returning that element as a tree and the raw XML +// declaration (e.g. ``) if present. +func parseXMLTree(dec *xml.Decoder) (*xmlNode, string, error) { + var xmlDecl string + for { + tok, err := dec.Token() + if err != nil { + return nil, "", err + } + switch t := tok.(type) { + case xml.ProcInst: + if t.Target == "xml" { + xmlDecl = fmt.Sprintf("", strings.TrimSpace(string(t.Inst))) + } + case xml.StartElement: + root, err := parseXMLElement(dec, t) + if err != nil { + return nil, "", err + } + return root, xmlDecl, nil + } + } +} + +// parseXMLElement reads dec until the matching end element for start, +// building the element subtree. +func parseXMLElement(dec *xml.Decoder, start xml.StartElement) (*xmlNode, error) { + n := &xmlNode{name: start.Name.Local, space: start.Name.Space} + for _, a := range start.Attr { + // xmlns / xmlns:* declarations are re-derived from Name.Space when + // rendering the root element; keep only "real" attributes here. + if a.Name.Space == "xmlns" || a.Name.Local == "xmlns" { + continue + } + n.attrs = append(n.attrs, a) + } + + var text bytes.Buffer + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + child, err := parseXMLElement(dec, t) + if err != nil { + return nil, err + } + n.children = append(n.children, child) + case xml.EndElement: + n.text = text.String() + return n, nil + case xml.CharData: + text.Write(t) + } + } +} + +// renderXMLNode writes n to out at the given indent depth, masking leaf +// text and attribute values per maskXMLValue. +func renderXMLNode(out *bytes.Buffer, n *xmlNode, depth int, unsafe bool) { + out.WriteString(strings.Repeat(" ", depth)) + out.WriteByte('<') + out.WriteString(n.name) + if depth == 0 && n.space != "" { + fmt.Fprintf(out, ` xmlns="%s"`, escapeXML(n.space)) + } + for _, a := range n.attrs { + attrName := a.Name.Local + if a.Name.Space != "" { + attrName = a.Name.Space + ":" + attrName + } + fmt.Fprintf(out, ` %s="%s"`, attrName, escapeXML(maskXMLValue(a.Name.Local, a.Value, unsafe))) + } + + hasText := strings.TrimSpace(n.text) != "" + if len(n.children) == 0 && !hasText { + out.WriteString(">\n") + return + } + + out.WriteByte('>') + if len(n.children) > 0 { + out.WriteByte('\n') + for _, c := range n.children { + renderXMLNode(out, c, depth+1, unsafe) + } + out.WriteString(strings.Repeat(" ", depth)) + } else { + out.WriteString(escapeXML(maskXMLValue(n.name, n.text, unsafe))) + } + out.WriteString("\n") +} + +func escapeXML(s string) string { + var buf bytes.Buffer + // xml.EscapeText never returns an error for a bytes.Buffer destination. + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} diff --git a/debuglogger/xmlmask_test.go b/debuglogger/xmlmask_test.go new file mode 100644 index 00000000..bb13341c --- /dev/null +++ b/debuglogger/xmlmask_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 debuglogger + +import ( + "strings" + "testing" +) + +const stsBody = ` +AROAEXAMPLE:sessionarn:aws:sts::123456789012:assumed-role/role/sessionhttps://idp.example.comASIAabcdefghijklmnopsupersecretvalue1234567890tokentokentokentoken2026-07-30T12:00:00Zsubject-123req-123` + +func TestMaskXMLBodyMasksSecretsAtDebugLevel(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + out, ok := maskXMLBody([]byte(stsBody)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true for well-formed XML") + } + got := string(out) + + for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken"} { + if strings.Contains(got, secret) { + t.Errorf("masked output leaked secret %q:\n%s", secret, got) + } + } + if !strings.Contains(got, "****") { + t.Errorf("expected SecretAccessKey to be fully masked:\n%s", got) + } + if !strings.Contains(got, "****") { + t.Errorf("expected SessionToken to be fully masked:\n%s", got) + } + // AccessKeyId is partially masked: first 4 chars visible. + if !strings.Contains(got, "ASIA****") { + t.Errorf("expected AccessKeyId to be partially masked with prefix visible:\n%s", got) + } + // Non-sensitive fields must survive untouched. + for _, want := range []string{ + `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`, + "AROAEXAMPLE:session", + "arn:aws:sts::123456789012:assumed-role/role/session", + "https://idp.example.com", + "2026-07-30T12:00:00Z", + "req-123", + } { + if !strings.Contains(got, want) { + t.Errorf("expected masked output to preserve %q:\n%s", want, got) + } + } + // The namespace must be declared exactly once (on the root), not + // redeclared on every nested element. + if n := strings.Count(got, "xmlns="); n != 1 { + t.Errorf("expected exactly one xmlns declaration, got %d:\n%s", n, got) + } +} + +func TestMaskXMLBodyUnsafeLevelShowsSecrets(t *testing.T) { + SetLevel(LevelUnsafe) + defer SetLevel(LevelSilent) + + out, ok := maskXMLBody([]byte(stsBody)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true for well-formed XML") + } + got := string(out) + + for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken", "ASIAabcdefghijklmnop"} { + if !strings.Contains(got, secret) { + t.Errorf("unsafe-level output should show secret %q in the clear:\n%s", secret, got) + } + } +} + +func TestMaskXMLBodyPreservesNestingAndAttributes(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + body := `valuevalue2` + out, ok := maskXMLBody([]byte(body)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true") + } + got := string(out) + + if strings.Count(got, "") != 2 { + t.Errorf("expected both nested Inner elements to survive:\n%s", got) + } + if !strings.Contains(got, `id="1"`) { + t.Errorf("expected attribute to survive:\n%s", got) + } +} + +func TestMaskXMLBodyRejectsMalformedOrNonXML(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + for _, body := range []string{ + "", + " ", + "", + `{"json":"body"}`, + "plain text body", + } { + if _, ok := maskXMLBody([]byte(body)); ok { + t.Errorf("maskXMLBody(%q): expected ok=false", body) + } + } +} + +func TestMaskPartial(t *testing.T) { + tests := []struct { + value string + want string + }{ + {"AKIAabcdefghijklmnop", "AKIA****"}, + {"ASIA", "****"}, + {"abc", "****"}, + {"", "****"}, + } + for _, tt := range tests { + if got := maskPartial(tt.value); got != tt.want { + t.Errorf("maskPartial(%q) = %q, want %q", tt.value, got, tt.want) + } + } +} diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index b49d79eb..ba5e66ab 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -114,10 +114,13 @@ type Config struct { // (e.g. "https://webui.example.com") to restrict cross-origin access. CORSAllowOrigin string - // Debug enables verbose debug logging to stdout, including details for - // signature verification steps. Not intended for production use. - Debug bool - // IAMDebug enables verbose IAM subsystem debug logging. + // LogLevel controls the debug logger: LevelSilent (default) prints + // nothing, LevelDebug prints full request/response details with + // secrets and tokens masked, and LevelUnsafe prints them unmasked. + // Never use LevelUnsafe in production. + LogLevel debuglogger.Level + // IAMDebug enables verbose IAM subsystem debug logging. Has no effect + // when LogLevel is LevelSilent. IAMDebug bool // Quiet suppresses per-request summary logging to stdout. Quiet bool @@ -627,9 +630,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.S3Options) > 0 { opts = append(opts, cfg.S3Options...) } - if cfg.Debug { - debuglogger.SetDebugEnabled() - } + debuglogger.SetLevel(cfg.LogLevel) if cfg.IAMDebug { debuglogger.SetIAMDebugEnabled() } @@ -808,7 +809,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.Quiet { admOpts = append(admOpts, s3api.WithAdminQuiet()) } - if cfg.Debug { + if cfg.LogLevel != debuglogger.LevelSilent { admOpts = append(admOpts, s3api.WithAdminDebug()) } if cfg.SocketPerm != "" { diff --git a/embedgw/iam.go b/embedgw/iam.go index f6f087ef..5d638171 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -60,8 +60,11 @@ type IAMConfig struct { // 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 + // LogLevel controls the debug logger: LevelSilent (default) prints + // nothing, LevelDebug prints full request/response details with + // secrets and tokens masked, and LevelUnsafe prints them unmasked. + // Never use LevelUnsafe in production. + LogLevel debuglogger.Level // Quiet suppresses per-request summary logging and startup output. Quiet bool // KeepAlive enables HTTP keep-alive on IAM API connections. @@ -208,9 +211,7 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.DisableOIDCThumbprintAutoFetch { opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) } - if cfg.Debug { - debuglogger.SetDebugEnabled() - } + debuglogger.SetLevel(cfg.LogLevel) if cfg.SocketPerm != "" { perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32) if err != nil { diff --git a/extra/example.conf b/extra/example.conf index c58920bb..cce8538c 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -393,9 +393,28 @@ ROOT_SECRET_ACCESS_KEY= # Debug / Diagnostics # ####################### -# The VGW_DEBUG option enables verbose debug log output to stdout. This output -# includes details for signature verification steps. This is generally only -# useful for debugging the S3 server, and should not be used in production. +# The VGW_LOG_LEVEL option controls the verbosity and safety of the debug +# logger's output to stdout, which includes full request/response headers +# and bodies, and details for signature verification steps. It accepts one +# of the following values: +# silent - (default) no debug output. +# debug - full request/response logging, with secrets and tokens (e.g. +# access keys, secret keys, session tokens, signatures, SSE-C +# customer keys) masked at the property level. +# unsafe - full request/response logging with NO masking. Every secret +# and token is printed to stdout in the clear. +# +# WARNING: be very careful with VGW_LOG_LEVEL=unsafe. It logs account +# secrets, session tokens, and other credentials to the console with no +# masking at all -- anyone who can read that output can replay them +# directly. Only use "unsafe" for local troubleshooting on a trusted +# machine, and never in production. +#VGW_LOG_LEVEL=silent + +# The VGW_DEBUG option is a deprecated alias for VGW_LOG_LEVEL=debug, kept +# only for backward compatibility. Setting it to true prints a deprecation +# warning to the console and enables debug-level logging; use VGW_LOG_LEVEL +# instead for finer-grained control (including "unsafe" mode). #VGW_DEBUG=false # The VGW_PPROF option enables the pprof HTTP server for profiling the S3 diff --git a/go.mod b/go.mod index 7b8cc2c3..f2798c2a 100644 --- a/go.mod +++ b/go.mod @@ -13,11 +13,13 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.13 github.com/aws/aws-sdk-go-v2/service/iam v1.59.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 github.com/aws/smithy-go v1.27.8 github.com/cespare/xxhash/v2 v2.3.0 github.com/davecgh/go-spew v1.1.1 github.com/go-ldap/ldap/v3 v3.4.14 github.com/gofiber/fiber/v3 v3.5.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/vault-client-go v0.4.3 @@ -56,12 +58,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect github.com/gofiber/schema v1.8.4 // indirect github.com/gofiber/utils/v2 v2.4.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect diff --git a/iamapi/authentication_test.go b/iamapi/authentication_test.go index fa3031cf..a99b5ed0 100644 --- a/iamapi/authentication_test.go +++ b/iamapi/authentication_test.go @@ -22,6 +22,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "regexp" "strings" "testing" @@ -305,6 +306,25 @@ func TestVerifyIAMAuthRejectsUnsignedQueryParameter(t *testing.T) { requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message) } +// TestVerifyIAMAuthRejectsRootQueryAuthWithSecurityToken confirms a security +// token tacked onto a root-signed presigned request is rejected outright +// (InvalidClientTokenId) rather than falling through to a +// signature-mismatch error — root's own access key is never a temporary +// one, so it can never legitimately carry a security token at all. +func TestVerifyIAMAuthRejectsRootQueryAuthWithSecurityToken(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.QuerySecurityToken, "bogus-token") + req.URL.RawQuery = query.Encode() + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + 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()) @@ -317,6 +337,105 @@ func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) { requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to a valid region. ") } +// TestVerifyIAMAuthRejectsExpiredQueryRequest confirms a presigned IAM +// request signed too long ago is rejected by the same fixed ±15-minute +// freshness window (ValidateDateAt) header auth uses — confirmed live +// (niksis02 profile): real IAM's query-auth ignores X-Amz-Expires entirely +// (see TestVerifyIAMAuthQueryIgnoresXAmzExpires) and instead rejects a +// stale signing time with SignatureDoesNotMatch: "Signature expired: ... +// is now earlier than ... (... - 15 min.)" — byte-for-byte what this +// codebase's own SignatureDoesNotMatchExpired already produces. +func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) { + app := newIAMAuthTestApp(t) + signedTwoHoursAgo := time.Now().UTC().Add(-2 * time.Hour) + req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", + nil, testRoot.Secret, iammiddleware.SigningRegion, signedTwoHoursAgo) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + + var errResp struct { + XMLName xml.Name `xml:"ErrorResponse"` + Error struct { + Type string + Code string + } + } + body := readBody(t, resp) + if err := xml.Unmarshal([]byte(body), &errResp); err != nil { + t.Fatalf("unmarshal IAM error: %v\n%s", err, body) + } + if resp.StatusCode != http.StatusForbidden || errResp.Error.Type != "Sender" || errResp.Error.Code != "SignatureDoesNotMatch" { + t.Fatalf("status=%d error=%#v, want 403 Sender/SignatureDoesNotMatch; body=%s", resp.StatusCode, errResp.Error, body) + } +} + +// TestVerifyIAMAuthQueryIgnoresXAmzExpires confirms IAM/STS query-auth +// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs +// — confirmed live (niksis02 profile) that real IAM's ListUsers accepts a +// presigned request with X-Amz-Expires omitted, non-numeric, negative, or +// far beyond S3's 604800-second maximum, every time. +func TestVerifyIAMAuthQueryIgnoresXAmzExpires(t *testing.T) { + for _, expires := range []string{"", "abc", "-5", "9999999"} { + t.Run(expires, func(t *testing.T) { + app := newIAMAuthTestApp(t) + target := "http://example.com/?Action=ListUsers&Version=2010-05-08" + if expires != "" { + target += "&X-Amz-Expires=" + expires + } + req := querySignedIAMRequest(t, http.MethodGet, target, 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)) + } + }) + } +} + +// TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned confirms a temporary +// (ASIA…) session's X-Amz-Security-Token header must itself be part of +// SignedHeaders — present-but-unsigned is now rejected instead of being +// silently dropped from the canonical request (see +// requiredHeaderAuthSignedHeaders). Before this fix, this exact request +// (correct token value, correct signature, token simply excluded from +// SignedHeaders) would have authenticated successfully. +func TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-tokenheader", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + body := []byte(url.Values{"Action": {"GetUser"}, "Version": {iamAPIVersion}}.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(sigv4auth.HeaderSecurityToken, session.SessionToken) + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + signer := vgwv4.NewSigner() + // Sign with only "host" listed — the security-token header is present + // on the wire but deliberately excluded from SignedHeaders, simulating + // a client (or tampering party) that never binds it to the signature. + if _, err := signer.SignHTTP(context.Background(), + aws.Credentials{AccessKeyID: session.AccessKeyId, SecretAccessKey: session.SecretAccessKey}, + req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC(), []string{"host"}); err != nil { + t.Fatalf("sign request: %v", err) + } + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "IncompleteSignature", + "The request signature does not conform to AWS standards. Header(s) not signed: x-amz-security-token.") +} + func TestVerifyIAMAuthRejectsMissingAuthorization(t *testing.T) { app := newIAMAuthTestApp(t) @@ -511,7 +630,7 @@ func newIAMAuthTestApp(t *testing.T) *fiber.App { func(ctx fiber.Ctx) (*Response, error) { return &Response{Status: http.StatusOK}, nil }, - iammiddleware.VerifyIAMAuth(&testRoot), + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceIAM, &testRoot, nil), )) return app } diff --git a/iamapi/authorization_test.go b/iamapi/authorization_test.go new file mode 100644 index 00000000..60730a78 --- /dev/null +++ b/iamapi/authorization_test.go @@ -0,0 +1,602 @@ +// 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" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + iamtypes "github.com/versity/versitygw/iamapi/types" +) + +// signedIAMActionAs signs params (as an "iam"-service request, matching +// every non-STS action) with an arbitrary access key/secret/session token, +// unlike signedIAMRequest/querySignedIAMRequest which always sign as root. +func signedIAMActionAs(t *testing.T, access, secret, sessionToken string, params url.Values) *http.Request { + t.Helper() + if !params.Has("Version") { + params.Set("Version", iamAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC()); err != nil { + t.Fatalf("sign iam request: %v", err) + } + return req +} + +func doSignedIAMActionAs(t *testing.T, server *IAMApiServer, access, secret, sessionToken string, params url.Values) *http.Response { + t.Helper() + req := signedIAMActionAs(t, access, secret, sessionToken, params) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// createTestUserWithAccessKey creates a user (and, if policyDocument != "", +// an inline policy for it) via root, and an access key for it, returning the +// key material tests sign requests with. +func createTestUserWithAccessKey(t *testing.T, server *IAMApiServer, userName, policyDocument string) (accessKeyID, secretAccessKey string) { + t.Helper() + + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {userName}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + if policyDocument != "" { + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {userName}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {policyDocument}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + + resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {userName}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateAccessKeyResponse + unmarshalXML(t, readBody(t, resp), &out) + return out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey +} + +func TestVerifyIAMPolicyAllowsGrantedAction(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "alice", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func TestVerifyIAMPolicyDeniesUngrantedAction(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "bob", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"CreateUser"}, "UserName": {"carol"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/bob is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicyDeniesUserWithNoPolicies(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "dave", "") + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"dave"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/dave is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +func TestVerifyIAMAuthRejectsInactiveAccessKey(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "erin", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`) + + resp := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAccessKey"}, + "UserName": {"erin"}, + "AccessKeyId": {accessKeyID}, + "Status": {"Inactive"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("UpdateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"erin"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestVerifyIAMAuthRejectsUnknownAccessKey(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doSignedIAMActionAs(t, server, "unknown-access-key-id", "does-not-matter", "", url.Values{"Action": {"ListUsers"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestIAMApiControllerGetCallerIdentityWithUser(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "frank", "") + + resp := doSignedSTSAction(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:iam::000000000000:user/frank" { + t.Fatalf("GetCallerIdentity user Arn = %q", out.Result.Arn) + } + if out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity user Account = %q", out.Result.Account) + } +} + +// createTestSession creates a role with rolePolicyDocument as its sole +// inline policy and directly stores a session assuming it (bypassing +// AssumeRoleWithWebIdentity's OIDC token verification, which needs a live +// provider) carrying sessionPolicyDocument as its session policy. +func createTestSession(t *testing.T, server *IAMApiServer, roleName, rolePolicyDocument, sessionPolicyDocument string) iamtypes.Session { + t.Helper() + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var createRoleOut iamtypes.CreateRoleResponse + unmarshalXML(t, readBody(t, resp), &createRoleOut) + role := createRoleOut.Result.Role + + resp = doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {roleName}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {rolePolicyDocument}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIATEST" + roleName, + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: role.Arn, + RoleName: roleName, + RoleID: role.RoleID, + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + Policy: sessionPolicyDocument, + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + return session +} + +func TestVerifyIAMPolicySessionUsesRolePolicy(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + session := createTestSession(t, server, "role-a", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + // UserName names an existing user (rather than the caller's own + // self-lookup form) so this specifically exercises the role's + // identity-based policy granting iam:GetUser, independent of GetUser's + // separate self-lookup-vs-named-lookup behavior. + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser (role-granted) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-a/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicySessionPolicyCanOnlyNarrowRolePermissions(t *testing.T) { + server := newIAMControllerTestServer(t) + // The role broadly allows both actions; the session policy only allows + // one of them. Effective permissions = role ∩ session policy, so the + // narrower session policy is what actually governs. + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + session := createTestSession(t, server, "role-b", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:GetUser","iam:CreateUser"],"Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser (allowed by both) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-b/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicyResourceScopedAllowDeniesDifferentResource(t *testing.T) { + server := newIAMControllerTestServer(t) + + for _, roleName := range []string{"role-x", "role-y"} { + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole(%s) status = %d, body=%s", roleName, resp.StatusCode, readBody(t, resp)) + } + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "gina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-x"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-x"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetRole(role-x) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + // The policy only names role-x's ARN as Resource; a request for role-y + // must not be authorized by it, even though the Action matches. + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-y"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/gina is not authorized to perform: iam:GetRole because no identity-based policy allows the iam:GetRole action") +} + +func TestVerifyIAMPolicySessionDeniedWhenStoredRoleIDNoLongerMatches(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-mismatch", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + // Simulate the role having been deleted and recreated (getting a new + // RoleID) while this session, minted against the old role, is still + // unexpired: mutate the stored session's RoleID so it no longer matches + // the role currently on record. + stale := session + stale.RoleID = "AROASTALEROLEID" + if _, err := server.store.CreateSession(context.Background(), stale); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedIAMActionAs(t, server, stale.AccessKeyId, stale.SecretAccessKey, stale.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {""}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-mismatch/my-session is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +func TestVerifyIAMPolicySessionPolicyCannotWidenRolePermissions(t *testing.T) { + server := newIAMControllerTestServer(t) + // The role only allows GetUser; a broad session policy cannot grant + // CreateUser on top of that. + session := createTestSession(t, server, "role-c", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-c/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +// TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource +// exercises the two-resource nature of a rename/path-move: AWS's UpdateUser +// requires permission on both the source object and the object being moved +// to (see the UpdateUser API's documented "Note" on required permissions). +// A policy scoped only to the source path must not authorize moving the +// user out of it. +func TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "irene", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":"arn:aws:iam::000000000000:user/developers/*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", + url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/irene is not authorized to perform: iam:UpdateUser because no identity-based policy allows the iam:UpdateUser action") +} + +// TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources is the +// positive counterpart: once the policy names both the source and the +// target ARN, the same rename/path-move succeeds. +func TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "judy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":["arn:aws:iam::000000000000:user/developers/alice","arn:aws:iam::000000000000:user/admins/alice"]}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", + url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("UpdateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyGetUserSelfLookupResourceScoped guards against +// GetUser's omitted-UserName ("look up my own identity") form resolving to +// "*" instead of the caller's own ARN: with only a wildcard fallback, a +// Resource-scoped policy naming the caller's own ARN could never authorize +// their own self-lookup, forcing callers to be granted Resource:"*" just to +// use the feature. +func TestVerifyIAMPolicyGetUserSelfLookupResourceScoped(t *testing.T) { + server := newIAMControllerTestServer(t) + + hankAccessKeyID, hankSecret := createTestUserWithAccessKey(t, server, "hank", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`) + + resp := doSignedIAMActionAs(t, server, hankAccessKeyID, hankSecret, "", url.Values{"Action": {"GetUser"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(self) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + ivyAccessKeyID, ivySecret := createTestUserWithAccessKey(t, server, "ivy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`) + + // A policy scoped to hank's ARN must not authorize ivy's self-lookup, + // which resolves against ivy's own ARN, not hank's. + resp = doSignedIAMActionAs(t, server, ivyAccessKeyID, ivySecret, "", url.Values{"Action": {"GetUser"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/ivy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped guards against +// GetAccessKeyLastUsed (which carries only AccessKeyId, never UserName) +// falling back to "*" instead of resolving the queried key's owning user: +// with only a wildcard fallback, a Resource-scoped policy could never +// authorize the action at all, and — once granted via Resource:"*" — could +// not stop a caller from looking up any other user's key. +func TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped(t *testing.T) { + server := newIAMControllerTestServer(t) + + ninaAccessKeyID, ninaSecret := createTestUserWithAccessKey(t, server, "nina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetAccessKeyLastUsed","Resource":"arn:aws:iam::000000000000:user/nina"}]}`) + + resp := doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "", + url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {ninaAccessKeyID}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetAccessKeyLastUsed(own key) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + oscarAccessKeyID, _ := createTestUserWithAccessKey(t, server, "oscar", "") + + // nina's policy only names her own ARN as Resource; it must not + // authorize looking up oscar's access key, even though the Action + // matches — the resource-level check resolves AccessKeyId to its + // owning user, not a wildcard. + resp = doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "", + url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {oscarAccessKeyID}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/nina is not authorized to perform: iam:GetAccessKeyLastUsed because no identity-based policy allows the iam:GetAccessKeyLastUsed action") +} + +func TestVerifyIAMPolicySecureTransportDenyAppliesToPlaintextRequest(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "paul", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"paul"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/paul is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive verifies that +// condition-key lookup treats key *names* (unlike their values) as +// case-insensitive, so a Deny written against this package's internal +// aws:SourceIp key using different casing is still evaluated, not silently +// treated as naming an absent key. +func TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "quinn", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"AWS:SOURCEIP":"false"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"quinn"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/quinn is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyPermanentUserHasUserId verifies that aws:userid is +// populated for a long-term IAM user principal, not only for a session (AWS +// sets aws:username and aws:userid simultaneously). A Deny guarding on its +// absence must not fire for a permanent user. +func TestVerifyIAMPolicyPermanentUserHasUserId(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "ray", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"aws:userid":"true"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"ray"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable verifies that +// ${aws:username} in a statement's Resource is substituted before matching, +// so a Deny scoped to the caller's own resource via this variable matches +// the actual resource ARN instead of letting the broader Allow win. +func TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "sam", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"sam"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/sam is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition verifies that +// aws:RequestTag/ and aws:TagKeys are populated from a Create action's +// own Tags parameter, so a Deny guarding against a specific tag value blocks +// the tagged create. +func TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "tina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"StringEquals":{"aws:RequestTag/env":"prod"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{ + "Action": {"CreateUser"}, + "UserName": {"newbie"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/tina is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") + + // A different tag value doesn't match the Deny's condition, so creation + // proceeds - confirming the Deny above was tag-value-specific, not a + // blanket denial of tagged creates. + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{ + "Action": {"CreateUser"}, + "UserName": {"newbie2"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"dev"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(env=dev) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource verifies that +// iam:ResourceTag/ (and, identically, the generic aws:ResourceTag/) +// is hydrated from an existing target resource's own stored tags, so a Deny +// guarding on it overrides the broad Allow underneath it when the target +// carries that tag. +func TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource(t *testing.T) { + server := newIAMControllerTestServer(t) + + // victor is the tagged target; his tag is set at creation time, via root. + if resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"victor"}, + "Tags.member.1.Key": {"sensitive"}, + "Tags.member.1.Value": {"true"}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(victor) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "wendy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"iam:ResourceTag/sensitive":"true"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/wendy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // The generic aws:ResourceTag/ form is populated identically to the + // iam:ResourceTag/ one. + accessKeyID2, secret2 := createTestUserWithAccessKey(t, server, "xander", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:ResourceTag/sensitive":"true"}}}]}`) + resp = doSignedIAMActionAs(t, server, accessKeyID2, secret2, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/xander is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // An untagged user isn't affected by either Deny. + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"yolanda"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(yolanda) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"yolanda"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(yolanda, untagged) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller verifies that +// aws:PrincipalTag/ is hydrated from the *calling* user's own stored +// tags, so a Deny guarding on it overrides the broad Allow underneath it +// when the caller carries that tag. +func TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller(t *testing.T) { + server := newIAMControllerTestServer(t) + + if resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"zack"}, + "Tags.member.1.Key": {"team"}, + "Tags.member.1.Value": {"contractor"}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + if resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"zack"}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},` + + `{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"zack"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateAccessKeyResponse + unmarshalXML(t, readBody(t, resp), &out) + + resp = doSignedIAMActionAs(t, server, out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey, "", url.Values{"Action": {"GetUser"}, "UserName": {"zack"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/zack is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // A caller without that tag isn't affected by the same policy shape. + untaggedAccessKeyID, untaggedSecret := createTestUserWithAccessKey(t, server, "abby", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`) + resp = doSignedIAMActionAs(t, server, untaggedAccessKeyID, untaggedSecret, "", url.Values{"Action": {"GetUser"}, "UserName": {"abby"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(abby, untagged principal) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} diff --git a/iamapi/controller.go b/iamapi/controller.go index 0fc3a5a3..5b06eef1 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,6 +17,7 @@ package iamapi import ( "errors" "fmt" + "slices" "time" "github.com/gofiber/fiber/v3" @@ -26,6 +27,7 @@ import ( "github.com/versity/versitygw/iamapi/policy" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/httpctx" ) type IAMApiController struct { @@ -115,17 +117,24 @@ func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) { 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 !ok || username == "" { + // Real IAM treats an omitted UserName as "look up the caller's own identity + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + switch { + case identity.IsRoot: + 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 + case identity.User != nil: + return &Response{Data: &types.GetUserResponse{ + Result: types.GetUserResult{User: *identity.User}, + }}, nil + default: + return nil, iamerr.ValidationError("Must specify userName when calling with non-User credentials") + } } if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err @@ -1042,3 +1051,255 @@ func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) ( return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil } + +func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, error) { + rawRoleArn, ok := iamutil.RequestParam(ctx, "RoleArn") + if !ok || rawRoleArn == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: RoleArn") + return nil, iamerr.MissingValue("roleArn") + } + if err := iamutil.ValidateRoleArnLength(rawRoleArn); err != nil { + return nil, err + } + + roleSessionName, ok := iamutil.RequestParam(ctx, "RoleSessionName") + if !ok || roleSessionName == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: RoleSessionName") + return nil, iamerr.MissingValue("roleSessionName") + } + if err := iamutil.ValidateRoleSessionName(roleSessionName); err != nil { + return nil, err + } + + webIdentityToken, ok := iamutil.RequestParam(ctx, "WebIdentityToken") + if !ok || webIdentityToken == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: WebIdentityToken") + return nil, iamerr.MissingValue("webIdentityToken") + } + if err := iamutil.ValidateWebIdentityTokenLength(webIdentityToken); err != nil { + return nil, err + } + + // PolicyArns (managed session policies) and ProviderId (legacy Login + // with Amazon support) are valid AssumeRoleWithWebIdentity parameters + // this implementation doesn't enforce. Rejecting them outright, rather + // than silently accepting and ignoring them + if iamutil.HasRequestParamPrefix(ctx, "PolicyArns.member.") { + debuglogger.Logf("AssumeRoleWithWebIdentity: PolicyArns is not supported") + return nil, iamerr.UnsupportedParameter("PolicyArns") + } + if providerID, ok := iamutil.RequestParam(ctx, "ProviderId"); ok && providerID != "" { + debuglogger.Logf("AssumeRoleWithWebIdentity: ProviderId is not supported") + return nil, iamerr.UnsupportedParameter("ProviderId") + } + + durationSeconds, err := iamutil.ParseDurationSeconds(ctx) + if err != nil { + return nil, err + } + + // sessionPolicy is an optional additional permissions filter on top of + // the assumed role's own policies (Effective permissions = Role + // identity-based permissions ∩ Session policy permissions, enforced by + // iammiddleware.VerifyIAMPolicy); it uses identity-policy grammar, not + // trust-policy grammar, same as PutUserPolicy/PutRolePolicy. + sessionPolicy, ok := iamutil.RequestParam(ctx, "Policy") + if ok && sessionPolicy != "" { + if len(sessionPolicy) > policy.MaxSessionPolicyBytes { + return nil, iamerr.ValueTooLong("policy", policy.MaxSessionPolicyBytes) + } + if err := policy.Validate("policy", sessionPolicy); err != nil { + return nil, err + } + if err := policy.Parse(sessionPolicy); err != nil { + return nil, err + } + } + + // Structural JWT parsing happens before the role is even looked up — + // a malformed token is rejected the same way regardless of whether + // RoleArn names a real role. + claims, err := iamutil.ParseWebIdentityClaims(webIdentityToken) + if err != nil { + return nil, err + } + + roleName, ok := iamutil.RoleNameFromAssumeArn(rawRoleArn, iamutil.DefaultAccountID) + if !ok { + debuglogger.Logf("AssumeRoleWithWebIdentity: RoleArn is not a role in this account: %q", rawRoleArn) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + role, err := c.store.GetRole(ctx.Context(), roleName) + if err != nil { + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q not found: %v", roleName, err) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + // RoleNameFromAssumeArn only extracted the final path segment; confirm + // the full ARN the caller supplied — path included — actually matches + // this role's own Arn. Without this, an ARN naming the right role name + // but a different (or missing) path would still resolve to, and assume, + // this role. + if rawRoleArn != role.Arn { + debuglogger.Logf("AssumeRoleWithWebIdentity: RoleArn %q does not match role %q's actual arn %q", rawRoleArn, roleName, role.Arn) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + if role.MaxSessionDuration > 0 && durationSeconds > role.MaxSessionDuration { + debuglogger.Logf("AssumeRoleWithWebIdentity: requested duration %ds exceeds role %q max session duration %ds", durationSeconds, roleName, role.MaxSessionDuration) + return nil, iamerr.DurationExceedsMaxSessionDuration() + } + + issuer, ok := iamutil.WebIdentityIssuer(claims) + if !ok { + debuglogger.Logf("AssumeRoleWithWebIdentity: token has no iss claim") + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + audience, originalAudience, err := iamutil.WebIdentityAudience(claims) + if err != nil { + return nil, err + } + + subject, _ := claims["sub"].(string) + rawIssuer, _ := claims["iss"].(string) + + now := time.Now().UTC().Truncate(time.Second) + wctx := policy.WebIdentityContext{ + ProviderURL: issuer, + Audience: audience, + OriginalAudience: originalAudience, + Subject: subject, + Claims: iamutil.ExtractClaimContext(claims), + SourceIP: ctx.IP(), + Secure: ctx.Secure(), + Now: now, + RoleSessionName: roleSessionName, + } + + lookup := func(federatedArn string) (string, bool) { + provider, err := c.store.GetOIDCProvider(ctx.Context(), federatedArn) + if err != nil { + return "", false + } + return provider.Url, true + } + + result, providerArn := policy.EvaluateWebIdentityTrust(role.AssumeRolePolicyDocument, lookup, wctx) + switch result { + case policy.NoPrincipal, policy.ExplicitlyDenied: + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q trust policy does not authorize this request", roleName) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + case policy.NoIssuerMatch, policy.ConditionFailed: + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q trust policy rejected the token's claims", roleName) + return nil, iamerr.InvalidIdentityTokenClaims() + } + + provider, err := c.store.GetOIDCProvider(ctx.Context(), providerArn) + if err != nil { + debuglogger.Logf("AssumeRoleWithWebIdentity: matched provider %q vanished before use: %v", providerArn, err) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + if len(provider.ClientIDList) == 0 || !slices.Contains(provider.ClientIDList, audience) { + debuglogger.Logf("AssumeRoleWithWebIdentity: audience %q not in provider %q ClientIDList", audience, providerArn) + return nil, iamerr.InvalidIdentityTokenClaims() + } + + verifiedClaims, err := iamutil.VerifyWebIdentitySignature(ctx.Context(), webIdentityToken, provider.Url, provider.ThumbprintList) + if err != nil { + return nil, err + } + if err := iamutil.VerifyWebIdentityExpiration(verifiedClaims, now); err != nil { + return nil, err + } + if err := iamutil.VerifyWebIdentityRequiredClaims(verifiedClaims, now); err != nil { + return nil, err + } + + accessKeyID, err := iamutil.GenerateTempAccessKeyID() + if err != nil { + return nil, err + } + secretAccessKey, err := iamutil.GenerateSecretAccessKey() + if err != nil { + return nil, err + } + sessionToken, err := iamutil.GenerateSessionToken() + if err != nil { + return nil, err + } + + expiration := now.Add(time.Duration(durationSeconds) * time.Second) + + session := types.Session{ + AccessKeyId: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + RoleArn: role.Arn, + RoleName: role.RoleName, + RoleID: role.RoleID, + RoleSessionName: roleSessionName, + Provider: providerArn, + Audience: audience, + Subject: subject, + CreateDate: now, + Expiration: expiration, + Policy: sessionPolicy, + } + if _, err := c.store.CreateSession(ctx.Context(), session); err != nil { + debuglogger.Logf("failed to store AssumeRoleWithWebIdentity session for access key %q: %v", accessKeyID, err) + return nil, err + } + + return &Response{Data: &types.AssumeRoleWithWebIdentityResponse{ + Result: types.AssumeRoleWithWebIdentityResult{ + Audience: audience, + AssumedRoleUser: types.AssumedRoleUser{ + AssumedRoleId: role.RoleID + ":" + roleSessionName, + Arn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, roleSessionName), + }, + Provider: rawIssuer, + Credentials: types.Credentials{ + AccessKeyId: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + Expiration: expiration, + }, + SubjectFromWebIdentityToken: subject, + PackedPolicySize: iamutil.PackedPolicySize(sessionPolicy), + }, + }}, nil +} + +func (c IAMApiController) GetCallerIdentity(ctx fiber.Ctx) (*Response, error) { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + + switch { + case identity.Session != nil: + session := identity.Session + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, session.RoleName, session.RoleSessionName), + UserId: session.RoleID + ":" + session.RoleSessionName, + Account: iamutil.DefaultAccountID, + }, + }}, nil + case identity.User != nil: + user := identity.User + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: user.Arn, + UserId: user.UserID, + Account: iamutil.DefaultAccountID, + }, + }}, nil + default: + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: fmt.Sprintf("arn:aws:iam::%s:root", iamutil.DefaultAccountID), + UserId: iamutil.DefaultAccountID, + Account: iamutil.DefaultAccountID, + }, + }}, nil + } +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 0f80d9d1..2f12d765 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -14,8 +14,15 @@ package iamapi import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "encoding/xml" "net/http" + "net/http/httptest" "net/url" "regexp" "slices" @@ -23,11 +30,15 @@ import ( "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" + "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" iamtypes "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" ) var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`) @@ -157,30 +168,72 @@ func TestIAMApiControllerUserLifecycle(t *testing.T) { requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user with name zoe cannot be found.") } +// TestIAMApiControllerGetRootUser confirms GetUser's self-lookup form +// (UserName omitted, the only way any real AWS SDK/CLI ever invokes it, +// since Query-protocol clients simply don't serialize an absent optional +// field — confirmed live: `aws iam get-user` with no --user-name, as root, +// succeeds and returns the root pseudo-user) and its non-standard explicit- +// empty-string equivalent both resolve to the actual authenticated caller — +// root, here, since doIAMAction always signs as root. func TestIAMApiControllerGetRootUser(t *testing.T) { server := newIAMControllerTestServer(t) - resp := doIAMAction(t, server, url.Values{ - "Action": {"GetUser"}, - "UserName": {""}, - }) + + for _, params := range []url.Values{ + {"Action": {"GetUser"}}, + {"Action": {"GetUser"}, "UserName": {""}}, + } { + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser root (params=%v) status = %d, body=%s", params, 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") + } + } +} + +// TestIAMApiControllerGetUserSelfLookupNonRoot confirms GetUser's +// self-lookup form resolves to the actual authenticated non-root caller — +// not always root, which was the bug this test guards against. +func TestIAMApiControllerGetUserSelfLookupNonRoot(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "ivan", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}}) if resp.StatusCode != http.StatusOK { - t.Fatalf("GetUser root status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + t.Fatalf("GetUser self-lookup 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") + if out.Result.User.UserName != "ivan" || out.Result.User.Arn != "arn:aws:iam::000000000000:user/ivan" { + t.Fatalf("GetUser self-lookup = %#v, want caller's own identity (ivan)", out.Result.User) } +} - missing := doIAMAction(t, server, url.Values{"Action": {"GetUser"}}) - requireIAMError(t, missing, http.StatusBadRequest, "Sender", "MissingParameter", "The request must contain the parameter UserName.") +// TestIAMApiControllerGetUserSelfLookupSessionRejected confirms an assumed- +// role session — which has no IAM user identity to self-look-up — gets +// AWS's own ValidationError rather than being told it's root or some +// arbitrary user. +func TestIAMApiControllerGetUserSelfLookupSessionRejected(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-selflookup", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}}) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "Must specify userName when calling with non-User credentials") } func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { @@ -2064,3 +2117,956 @@ func requireUserTags(t *testing.T, tags []iamtypes.Tag) { t.Fatalf("Tags = %#v, want env=test and empty=", tags) } } + +// requireSTSError is requireIAMError's counterpart for the two STS actions: +// their errors render under STS's namespace instead of IAM's, except +// InvalidAction (a request whose Version doesn't resolve to any known +// action, so there's no specific service to attribute the fault to yet), +// which always uses the generic AWS fault namespace. +func requireSTSError(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 STS error: %v\n%s", err, body) + } + + wantNamespace := iamerr.STSNamespace + 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") + } +} + +// doSTSAction sends params as an unsigned POST request — every one of +// these tests either exercises AssumeRoleWithWebIdentity (which requires no +// credentials at all) or deliberately omits auth to check the resulting +// error, so signing is opt-in via signedSTSRequest instead of the default. +func doSTSAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Response { + t.Helper() + if !params.Has("Version") { + params.Set("Version", stsAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// signedSTSRequest builds an STS-style request (Credential scoped to +// "sts", matching a real STS SDK client) signed with the given +// credentials, optionally carrying an X-Amz-Security-Token header for +// temporary credentials. +func signedSTSRequest(t *testing.T, access, secret, sessionToken string, params url.Values) *http.Request { + t.Helper() + if !params.Has("Version") { + params.Set("Version", stsAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "sts", iammiddleware.SigningRegion, time.Now().UTC()); err != nil { + t.Fatalf("sign sts request: %v", err) + } + return req +} + +func doSignedSTSAction(t *testing.T, server *IAMApiServer, access, secret, sessionToken string, params url.Values) *http.Response { + t.Helper() + req := signedSTSRequest(t, access, secret, sessionToken, params) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// validWebIdentityToken is a structurally valid (but unverifiable — no +// registered provider will ever match its issuer) JWT carrying every claim +// AWS requires (including iat — its absence would itself be a rejection +// reason, see VerifyWebIdentityRequiredClaims), sufficient for exercising +// every AssumeRoleWithWebIdentity validation step that runs before the +// network call to fetch a provider's signing keys. +const validWebIdentityToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJpc3MiOiJodHRwczovL3VucmVnaXN0ZXJlZC5leGFtcGxlLmNvbSIsImF1ZCI6ImNsaWVudDEiLCJzdWIiOiJ1c2VyMSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ." + + "c2lnbmF0dXJl" + +func TestIAMApiControllerAssumeRoleWithWebIdentityRequiresNoAuth(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A completely unsigned request (no Authorization header, no query + // auth params at all) must still reach business logic rather than + // being rejected for missing credentials — the entire point of this + // action is that no AWS credentials are required. + resp := doSTSAction(t, server, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must not be null") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityValidationErrors(t *testing.T) { + server := newIAMControllerTestServer(t) + const roleArn = "arn:aws:iam::000000000000:role/does-not-exist" + + tests := []struct { + name string + params url.Values + wantStatus int + wantErrType string + wantCode string + wantMessage string + }{ + { + name: "missing RoleSessionName", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleSessionName' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid RoleSessionName characters", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"bad session!!"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value 'bad session!!' at 'roleSessionName' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w+=,.@-]*", + }, + { + name: "missing WebIdentityToken", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'webIdentityToken' failed to satisfy constraint: Member must not be null", + }, + { + name: "malformed (non-JWT) token", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {"not-a-real-jwt-token"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "InvalidIdentityToken", + wantMessage: "The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token)", + }, + { + name: "duration too low", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"100"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value '100' at 'durationSeconds' failed to satisfy constraint: Member must have value greater than or equal to 900", + }, + { + name: "duration too high", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"50000"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value '50000' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200", + }, + { + name: "RoleArn too short", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"short"}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must have length greater than or equal to 20", + }, + { + name: "RoleArn too long", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn + strings.Repeat("a", 2048)}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must have length less than or equal to 2048", + }, + { + name: "WebIdentityToken too short", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}, "WebIdentityToken": {"ab"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'webIdentityToken' failed to satisfy constraint: Member must have length greater than or equal to 4", + }, + { + name: "nonexistent role", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusForbidden, + wantErrType: "Sender", + wantCode: "AccessDenied", + wantMessage: "Not authorized to perform sts:AssumeRoleWithWebIdentity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := doSTSAction(t, server, tt.params) + requireSTSError(t, resp, tt.wantStatus, tt.wantErrType, tt.wantCode, tt.wantMessage) + }) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityErrorsUseSTSNamespace(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + body := readBody(t, resp) + if !strings.Contains(body, `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`) { + t.Fatalf("error response missing STS namespace: %s", body) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityDurationExceedsRoleMax(t *testing.T) { + server := newIAMControllerTestServer(t) + + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/my-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "DurationSeconds": {"7200"}, // role's default MaxSessionDuration is 3600 + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "The requested DurationSeconds exceeds the MaxSessionDuration set for this role.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityNoMatchingPrincipal(t *testing.T) { + server := newIAMControllerTestServer(t) + + // The trust policy's Federated principal never corresponds to a real, + // registered OIDC provider (it was never created) — this is reported + // identically to a nonexistent role, never confirming or denying + // whether the role itself exists. + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"dangling-trust-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/dangling-trust-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRejectsUnsupportedParams(t *testing.T) { + tests := []struct { + name string + wantParam string // the parameter name UnsupportedParameter's message names; defaults to name if empty + params url.Values + }{ + { + name: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns member 2", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.2.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns member 10", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.10.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns with an index gap (member 3 only, no 1 or 2)", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.3.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns empty-but-present value", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {""}, + }, + }, + { + name: "ProviderId", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "ProviderId": {"www.amazon.com"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, tt.params) + wantParam := tt.wantParam + if wantParam == "" { + wantParam = tt.name + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidInput", wantParam+" is not supported by this implementation.") + }) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRejectsPolicyArnsInQueryString(t *testing.T) { + server := newIAMControllerTestServer(t) + + params := url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "Version": {stsAPIVersion}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + } + req := httptest.NewRequest(http.MethodGet, "http://example.com/?"+params.Encode(), nil) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidInput", "PolicyArns is not supported by this implementation.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRoleArnPathMismatch(t *testing.T) { + server := newIAMControllerTestServer(t) + + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"path-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + // "path-role" was created with the default "/" path, so its real Arn is + // arn:...:role/path-role — not arn:...:role/some/path/path-role. Only + // the role name matched; the full ARN (path included) must not. + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/some/path/path-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +// webIdentityTokenWithClaims builds an unverified (but structurally valid) +// JWT carrying claims — sufficient for every AssumeRoleWithWebIdentity trust +// evaluation test below, since none of them ever reach real signature +// verification (a trust-policy mismatch, audience mismatch, or condition +// failure is always detected first). +func webIdentityTokenWithClaims(t *testing.T, claims map[string]any) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".c2lnbmF0dXJl" +} + +// createTestOIDCProviderForTrust creates a real, registered OIDC provider at +// url (scheme included) with clientIDs, returning its ARN for use as a role +// trust policy's Federated principal. +func createTestOIDCProviderForTrust(t *testing.T, server *IAMApiServer, url_, clientID string) string { + t.Helper() + params := url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {url_}, + "ThumbprintList.member.1": {"6938fd4d98bab03faadb97b34396831e3780aea1"}, + } + if clientID != "" { + params.Set("ClientIDList.member.1", clientID) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, resp), &out) + return out.Result.OpenIDConnectProviderArn +} + +func createTestRoleForTrust(t *testing.T, server *IAMApiServer, roleName, trustPolicy string) { + t.Helper() + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {trustPolicy}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityNoIssuerMatch(t *testing.T) { + server := newIAMControllerTestServer(t) + + // The trust policy's Federated principal resolves to a real, registered + // provider — but that provider's own Url doesn't match the token's iss + // claim. Unlike NoPrincipal (no such provider at all), this is reported + // as InvalidIdentityToken, confirming the role's existence is no longer + // masked once its trust policy references at least one real provider. + providerArn := createTestOIDCProviderForTrust(t, server, "https://registered.example.com", "client1") + createTestRoleForTrust(t, server, "no-issuer-match-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://different-issuer.example.com", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/no-issuer-match-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityConditionFailed(t *testing.T) { + server := newIAMControllerTestServer(t) + + providerArn := createTestOIDCProviderForTrust(t, server, "https://cond.example.com", "client1") + createTestRoleForTrust(t, server, "condition-failed-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"cond.example.com:sub":"expected-user"}}}]}`) + + // Provider matches (iss == cond.example.com) but sub doesn't satisfy the + // trust statement's Condition block. + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://cond.example.com", "aud": "client1", "sub": "someone-else", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/condition-failed-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityExplicitDeny(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A broad Allow is present, but a Deny statement matching the same + // provider/action/condition takes precedence — reported as AccessDenied, + // identically to a role that doesn't authorize the caller at all, never + // as InvalidIdentityToken (Deny is a distinct outcome from a mismatched + // condition on an Allow). + providerArn := createTestOIDCProviderForTrust(t, server, "https://deny.example.com", "client1") + createTestRoleForTrust(t, server, "explicit-deny-role", + `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"},`+ + `{"Effect":"Deny","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"deny.example.com:sub":"blocked-user"}}}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://deny.example.com", "aud": "client1", "sub": "blocked-user", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/explicit-deny-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityAudienceNotInClientIDList(t *testing.T) { + server := newIAMControllerTestServer(t) + + // Trust evaluation passes (the provider matches iss, no Condition to + // fail), but the token's audience isn't among the provider's own + // ClientIDList — a distinct check, made only after trust evaluation + // succeeds, that still reports the same InvalidIdentityToken as a + // Condition failure would. + providerArn := createTestOIDCProviderForTrust(t, server, "https://aud-mismatch.example.com", "allowed-client") + createTestRoleForTrust(t, server, "audience-mismatch-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://aud-mismatch.example.com", "aud": "not-the-allowed-client", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/audience-mismatch-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityEmptyClientIDList(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A provider with no registered client IDs at all can never satisfy the + // audience check, no matter what the token's aud claim is. + providerArn := createTestOIDCProviderForTrust(t, server, "https://no-clients.example.com", "") + createTestRoleForTrust(t, server, "empty-client-list-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://no-clients.example.com", "aud": "anything", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/empty-client-list-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityMultiplePrincipalsInArray(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A Federated principal can be a JSON array of ARNs, not just a bare + // string — the token's issuer only needs to match one of them. Both + // providers use loopback IP hosts (rather than DNS names) so that once + // the flow reaches signature verification, the SSRF guard rejects the + // dial immediately and deterministically instead of the test depending + // on (and being slowed or flaked by) real DNS resolution. + otherProviderArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.2", "client1") + matchingProviderArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.3", "client1") + createTestRoleForTrust(t, server, "multi-principal-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":["`+otherProviderArn+`","`+matchingProviderArn+`"]},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://127.0.0.3", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/multi-principal-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + // Passes trust evaluation and the audience check; fails only at the + // network-dependent signature verification step (see the IDP + // communication error test below for that path exercised + // deterministically) — here it's enough to confirm it gets that far + // rather than being rejected as AccessDenied/InvalidIdentityToken. + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements") +} + +// TestIAMApiControllerAssumeRoleWithWebIdentityIDPCommunicationError confirms +// the network-dependent signature-verification step is wired all the way +// through the real HTTP action handler: a provider Url that's an IP literal +// in a private/loopback range is rejected by VerifyWebIdentitySignature's +// mandatory SSRF guard before any real network attempt, deterministically +// and without requiring outbound network access from the test environment — +// the same technique +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// uses for CreateOpenIDConnectProvider's auto-fetch path. +func TestIAMApiControllerAssumeRoleWithWebIdentityIDPCommunicationError(t *testing.T) { + server := newIAMControllerTestServer(t) + + providerArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.1", "client1") + createTestRoleForTrust(t, server, "idp-comm-error-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://127.0.0.1", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/idp-comm-error-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements") +} + +func TestIAMApiControllerGetCallerIdentityRoot(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doSignedSTSAction(t, server, testRoot.Access, testRoot.Secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + body := readBody(t, resp) + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, body, &out) + if out.Result.Arn != "arn:aws:iam::000000000000:root" { + t.Fatalf("GetCallerIdentity root Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "000000000000" || out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity root UserId/Account = %q/%q", out.Result.UserId, out.Result.Account) + } + if !strings.Contains(body, `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`) { + t.Fatalf("success response missing STS namespace: %s", body) + } +} + +func TestIAMApiControllerGetCallerIdentityNoAuth(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "MissingAuthenticationToken", "Request is missing Authentication Token") +} + +func TestIAMApiControllerGetCallerIdentityWrongVersionIsInvalidAction(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSignedSTSAction(t, server, testRoot.Access, testRoot.Secret, "", url.Values{ + "Action": {"GetCallerIdentity"}, + "Version": {iamAPIVersion}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidAction", "Could not find operation GetCallerIdentity for version "+iamAPIVersion) +} + +func TestIAMApiControllerGetCallerIdentityWithSession(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTSESSION1234567", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/my-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "AROAtESTROLE123456789:my-session" { + t.Fatalf("GetCallerIdentity session UserId = %q", out.Result.UserId) + } + if out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity session Account = %q", out.Result.Account) + } +} + +func TestIAMApiControllerGetCallerIdentityWithSessionWrongToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTSESSION7654321", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Right access key and secret, but a security token that doesn't match + // the stored session must still be rejected. + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, "wrong-token", + url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestIAMApiControllerGetCallerIdentityWithExpiredSession(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTEXPIRED1234567", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(-time.Minute), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +// TestIAMApiControllerGetCallerIdentityWithSessionAfterRoleDeleted confirms +// resolveSessionIdentity's documented behavior: a signature-valid, unexpired +// session still authenticates and answers GetCallerIdentity even after its +// assumed role has since been deleted — real STS credentials are +// self-contained and don't re-check role existence on every call. +func TestIAMApiControllerGetCallerIdentityWithSessionAfterRoleDeleted(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTDELETEDROLE123", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/ephemeral-role", + RoleName: "ephemeral-role", + RoleID: "AROAtESTROLE987654321", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + // Note: no CreateRole call — the role this session names never existed + // (or, equivalently, was deleted after the session was minted). + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/ephemeral-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "AROAtESTROLE987654321:my-session" { + t.Fatalf("GetCallerIdentity session UserId = %q", out.Result.UserId) + } +} + +// TestIAMApiControllerGetCallerIdentityIncorrectServiceScope confirms the +// shared sigv4 auth pipeline reports the STS-specific service name ("sts", +// not "iam") when GetCallerIdentity is signed with a Credential scoped to +// the wrong service — the same generic mapIAMSigV4Error path +// authentication_test.go already exercises for "iam"-scoped actions, +// parameterized here by the "sts" service GetCallerIdentity actually signs +// for. +func TestIAMApiControllerGetCallerIdentityIncorrectServiceScope(t *testing.T) { + server := newIAMControllerTestServer(t) + + req := signedSTSRequest(t, testRoot.Access, testRoot.Secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + authHdr := req.Header.Get("Authorization") + authHdr = strings.Replace(authHdr, "/sts/aws4_request", "/iam/aws4_request", 1) + req.Header.Set("Authorization", authHdr) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to correct service: 'sts'.") +} + +// querySignedSTSRequest builds a genuinely presigned (query-string SigV4) +// GET request scoped to "sts" (matching a real STS SDK client's presigned +// URL), signed with the given credentials. When sessionToken is non-empty, +// the real v4 signer adds X-Amz-Security-Token to the query string itself +// — the same way AWS's own SDKs presign a request for temporary +// credentials (confirmed live against real AWS: such a request, submitted +// as a plain HTTP GET with no Authorization header, succeeds). +func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target string) *http.Request { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, target, nil) + hash := sha256.Sum256(nil) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + signedURL, _, err := signer.PresignHTTP(context.Background(), creds, req, payloadHash, "sts", iammiddleware.SigningRegion, time.Now().UTC()) + if err != nil { + t.Fatalf("presign sts request: %v", err) + } + + return httptest.NewRequest(http.MethodGet, signedURL, nil) +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken confirms a +// temporary (ASIA…) session CAN authenticate via query-string (presigned +// URL) auth when X-Amz-Security-Token matches the session — confirmed live +// against real AWS (a genuine sts.PresignClient-generated presigned +// GetCallerIdentity request, signed with real ASIA… credentials and +// submitted as a plain HTTP GET, returns 200). +func TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTQUERYAUTH12345", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + req := querySignedSTSRequest(t, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/my-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthWithMismatchedSessionToken +// confirms a session presented via query auth still must carry the correct +// X-Amz-Security-Token — an unrelated token doesn't let a stolen/guessed +// temporary access key and secret through. +func TestIAMApiControllerGetCallerIdentityQueryAuthWithMismatchedSessionToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTQUERYAUTH99999", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + req := querySignedSTSRequest(t, session.AccessKeyId, session.SecretAccessKey, "wrong-token", + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthLongTermCredentialWithTokenRejected +// confirms a long-term (AKIA…) user credential carrying a security token in +// the query string is still always rejected outright — that combination +// can never be legitimate, since a long-term secret never has a +// corresponding session token to match. +func TestIAMApiControllerGetCallerIdentityQueryAuthLongTermCredentialWithTokenRejected(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "heidi", "") + + req := querySignedSTSRequest(t, accessKeyID, secret, "", + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + q := req.URL.Query() + q.Set(sigv4auth.QuerySecurityToken, "bogus-token") + req.URL.RawQuery = q.Encode() + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 14d24010..533b6bd3 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -17,6 +17,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/xml" + "errors" "fmt" "net/http" "strings" @@ -26,6 +27,7 @@ import ( const ( Namespace = "https://iam.amazonaws.com/doc/2010-05-08/" AWSFaultNamespace = "http://webservices.amazon.com/AWSFault/2005-15-09" + STSNamespace = "https://sts.amazonaws.com/doc/2011-06-15/" ) type ErrorType string @@ -253,6 +255,24 @@ func GetAPIError(code ErrorCode) Error { return errorCodeResponse[ErrInternalFailure] } +// WithNamespace returns err with its XML namespace overridden to namespace, +// for errors that must render under a different service's namespace than +// the one they were originally constructed with (STS actions sharing this +// gateway's IAM endpoint being the only current case). It never overrides +// an already-explicit namespace (e.g. InvalidAction's AWSFaultNamespace, +// used for a request whose Version doesn't even resolve to a known +// action. +func WithNamespace(err error, namespace string) error { + var apiErr Error + if errors.As(err, &apiErr) { + if apiErr.XMLNamespace == "" { + apiErr.XMLNamespace = namespace + } + return apiErr + } + return err +} + 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 @@ -506,6 +526,71 @@ func OpenIdIdpCommunicationError(url string) Error { return newSenderError("OpenIdIdpCommunicationError", fmt.Sprintf("Could not connect to %s", url), http.StatusBadRequest) } +func IncorrectServiceScope(expectedService string) Error { + return newSenderError("SignatureDoesNotMatch", fmt.Sprintf("Credential should be scoped to correct service: '%s'.", expectedService), http.StatusBadRequest) +} + +func InvalidIdentityTokenMalformed() Error { + return newSenderError("InvalidIdentityToken", "The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token)", http.StatusBadRequest) +} + +func InvalidIdentityTokenClaims() Error { + return newSenderError("InvalidIdentityToken", "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.", http.StatusBadRequest) +} + +func InvalidIdentityTokenMultipleAudiences() Error { + return newSenderError("InvalidIdentityToken", "Token audience contains more than one audience while authorized party is not present", http.StatusBadRequest) +} + +func InvalidIdentityTokenIDPCommunicationError() Error { + return newSenderError("InvalidIdentityToken", "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements", http.StatusBadRequest) +} + +func ExpiredWebIdentityToken(now, exp int64) Error { + return newSenderError("ExpiredTokenException", fmt.Sprintf("Token expired: current date/time %d must be before the expiration date/time %d", now, exp), http.StatusBadRequest) +} + +func UnsupportedParameter(parameter string) Error { + return newSenderError("InvalidInput", fmt.Sprintf("%s is not supported by this implementation.", parameter), http.StatusBadRequest) +} + +func InvalidIdentityTokenMissingClaim(claim string) Error { + return newSenderError("InvalidIdentityToken", fmt.Sprintf("Missing a required claim: %s.", claim), http.StatusBadRequest) +} + +func AccessDeniedAssumeRoleWithWebIdentity() Error { + return newSenderError("AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity", http.StatusForbidden) +} + +func InvalidRoleSessionName(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'roleSessionName' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w+=,.@-]*", value)) +} + +func DurationSecondsTooLow(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'durationSeconds' failed to satisfy constraint: Member must have value greater than or equal to 900", value)) +} + +func DurationSecondsTooHigh(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200", value)) +} + +func DurationExceedsMaxSessionDuration() Error { + return ValidationError("The requested DurationSeconds exceeds the MaxSessionDuration set for this role.") +} + +func AccessDeniedIAMAction(callerArn, action string) Error { + return newSenderError("AccessDenied", fmt.Sprintf( + "User: %s is not authorized to perform: %s because no identity-based policy allows the %s action", + callerArn, action, action, + ), http.StatusForbidden) +} + +func ConcurrentModification() Error { + return newSenderError("ConcurrentModificationException", + "The request was rejected because multiple requests to change this object were submitted simultaneously. Wait a few minutes and submit your request again.", + http.StatusConflict) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iammiddleware/auth.go b/iamapi/internal/iammiddleware/auth.go index aaf09fd0..c79284ee 100644 --- a/iamapi/internal/iammiddleware/auth.go +++ b/iamapi/internal/iammiddleware/auth.go @@ -14,12 +14,17 @@ package iammiddleware import ( + "context" "errors" "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/types" + "github.com/versity/versitygw/internal/httpctx" "github.com/versity/versitygw/internal/sigv4auth" ) @@ -28,61 +33,245 @@ const ( timeExpiration = 15 * time.Minute ) -var requiredSignedHeaders = []string{"host"} +// requiredSignedHeaders is the header-auth SignedHeaders policy for a +// permanent (root or AKIA…) credential. requiredTempSignedHeaders is the +// counterpart for a temporary (ASIA…) session credential: it additionally +// requires the session-token header be signed whenever it's present, +// matching standard AWS SDK behavior — defense in depth on top of the +// independent, access-key-bound SessionToken equality check in +// resolveSessionIdentity, so the header can't be silently dropped from the +// canonical request and left unbound to the signature. +// +// This only applies to header auth. Query-string (presigned) auth carries +// the token as a query parameter instead, which createPresignedHTTPRequestFromCtx +// already includes in the signed canonical query string regardless of +// SignedHeaders, so requiredSignedHeaders (unconditionally "host") is used +// for both root/permanent and session query-auth requests. +var ( + requiredSignedHeaders = []string{"host"} + requiredTempSignedHeaders = []string{"host", sigv4auth.HeaderSecurityToken} +) + +// requiredHeaderAuthSignedHeaders returns the SignedHeaders policy +// checkSignature enforces for header-based auth, based on whether accessKey +// is a temporary (ASIA…) session credential. +func requiredHeaderAuthSignedHeaders(accessKey string) []string { + if iamutil.IsTempAccessKeyID(accessKey) { + return requiredTempSignedHeaders + } + return requiredSignedHeaders +} type RootCredentials struct { Access string Secret string } -func VerifyIAMAuth(root *RootCredentials) fiber.Handler { +// IdentityStore resolves an access key id to the session or long-term user +// that owns it, and resolves named resources for policy evaluation. +// storage.Storer satisfies this directly. +type IdentityStore interface { + GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) + GetUser(ctx context.Context, username string) (*types.User, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error +} + +// VerifyIAMAuth authenticates a request against service (sigv4auth.ServiceIAM +// or sigv4auth.ServiceSTS). +// +// Three kinds of credential are accepted: the configured root user, a +// long-term (AKIA…) IAM user access key, or a temporary (ASIA…) session +// minted by AssumeRoleWithWebIdentity. Whichever it is, the resolved +// identity (and, for a user/session, its policy documents) is stored via +// httpctx.ContextKeyCallerIdentity for the policy middleware and controllers +// to read back. Root bypasses the policy middleware entirely +func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) fiber.Handler { return func(ctx fiber.Ctx) error { - authData, tdate, queryAuth, err := parseIAMAuth(ctx) + authData, tdate, queryAuth, err := parseIAMAuth(ctx, service) if err != nil { return err } - if authData.Access != root.Access { + // A security token in the query string is only ever legitimate + // alongside a temporary (ASIA…) access key — reject it outright for + // root or any long-term (AKIA…) credential before any signature + // work, the same way for both, rather than letting it fall through + // to a signature-mismatch error once a tampered/unsigned token + // param invalidates the canonical query string. + if queryAuth && !iamutil.IsTempAccessKeyID(authData.Access) && + ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) { return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } - contentLength, err := parseContentLength(ctx.Get("Content-Length")) + if authData.Access == root.Access { + if err := checkSignature(ctx, authData, root.Secret, tdate, queryAuth, service); err != nil { + return err + } + httpctx.ContextKeyCallerIdentity.Set(ctx, types.Identity{IsRoot: true}) + return nil + } + + identity, secret, err := resolveIdentity(ctx, store, authData, queryAuth) 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) + if err := checkSignature(ctx, authData, secret, tdate, queryAuth, service); err != nil { + return err } + httpctx.ContextKeyCallerIdentity.Set(ctx, *identity) + if identity.User != nil { + recordAccessKeyUsage(ctx.Context(), store, authData.Access, service) + } return nil } } -func parseIAMAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) { +// recordAccessKeyUsage best-effort-updates a permanent access key's +// GetAccessKeyLastUsed metadata (service, region, and timestamp) after it +// successfully authenticates a request, matching real IAM's behavior. A +// failure is only logged, never returned, since this is purely +// informational metadata and a lost update under concurrent use is +// immaterial. Called synchronously: a Storer implementation for which this +// update is network-bound (e.g. Vault) is expected to make it non-blocking +// itself rather than adding that latency to every authenticated request +func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKeyID, service string) { + if err := store.RecordAccessKeyUsage(reqCtx, accessKeyID, service, SigningRegion, time.Now().UTC()); err != nil { + debuglogger.Logf("failed to record access key last-used metadata for %q: %v", accessKeyID, err) + } +} + +// resolveIdentity resolves authData.Access to a session or long-term user, +// by its AKIA…/ASIA… prefix, and returns the generic identity the rest of +// the request pipeline uses along with the secret VerifyIAMAuth checks the +// signature against. It does not itself verify the SigV4 signature — the +// caller does that next, so a stolen/guessed access key or session token +// alone is never sufficient. +// +// A temporary session can be used via query-string (presigned URL) +// authentication — real AWS accepts X-Amz-Security-Token as a query +// parameter for exactly this (confirmed live: a genuine presigned +// sts:GetCallerIdentity request signed with temporary/session credentials, +// carrying X-Amz-Security-Token in the query string, succeeds against real +// AWS). VerifyIAMAuth already rejects a security token paired with any +// non-temporary credential (root included) before this is ever reached. +func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { + if store == nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + if iamutil.IsTempAccessKeyID(authData.Access) { + return resolveSessionIdentity(ctx, store, authData, queryAuth) + } + return resolveUserIdentity(ctx, store, authData) +} + +func resolveSessionIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { + session, err := store.GetSession(ctx.Context(), authData.Access) + if err != nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + token := ctx.Get(sigv4auth.HeaderSecurityToken) + if queryAuth { + token = ctx.Query(sigv4auth.QuerySecurityToken) + } + if token == "" || !sigv4auth.SecureCompare(token, session.SessionToken) { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + // A signature-valid, unexpired session still authenticates even if its + // role has since been deleted — real STS credentials are self-contained + // and don't re-check role existence on every call. What such a session + // can no longer do is get any IAM action past the policy middleware: + // with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies + // by default, same effective outcome as an explicit rejection here would + // have had for every pipeline except GetCallerIdentity, which needs + // none of this and must keep working regardless. + // + // The reloaded role must also still be the *same* role the session was + // originally minted against — RoleID and Arn, both captured in the + // session at AssumeRoleWithWebIdentity time, must match the freshly + // loaded role's own values. Without this check, deleting a role and + // recreating one of the same name (necessarily getting a new RoleID) + // would let every pre-existing session for the old role silently + // inherit whatever policies the new role happens to carry. + identity := &types.Identity{ + Session: session, + SessionPolicy: session.Policy, + } + if role, err := store.GetRole(ctx.Context(), session.RoleName); err == nil && + role.RoleID == session.RoleID && role.Arn == session.RoleArn { + identity.Role = role + identity.IdentityPolicies = role.Policies.Inline + } + return identity, session.SecretAccessKey, nil +} + +func resolveUserIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData) (*types.Identity, string, error) { + user, err := store.GetUserByAccessKeyID(ctx.Context(), authData.Access) + if err != nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + var keyEntry *types.AccessKeyEntry + for i := range user.AccessKeys { + if user.AccessKeys[i].AccessKeyId == authData.Access { + keyEntry = &user.AccessKeys[i] + break + } + } + if keyEntry == nil || keyEntry.Status != iamutil.AccessKeyStatusActive { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + identity := &types.Identity{ + User: user, + IdentityPolicies: user.Policies.Inline, + } + return identity, keyEntry.SecretAccessKey, nil +} + +func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, tdate time.Time, queryAuth bool, service string) error { + contentLength, err := parseContentLength(ctx.Get("Content-Length")) + if err != nil { + return err + } + + payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw()) + if queryAuth { + _, err = sigv4auth.CheckQuerySignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + Service: service, + RequiredSignedHeaders: requiredSignedHeaders, + }) + } else { + _, err = sigv4auth.CheckSignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + Service: service, + RequiredSignedHeaders: requiredHeaderAuthSignedHeaders(authData.Access), + }) + } + if err != nil { + return mapIAMSigV4Error(err, service) + } + return nil +} + +func parseIAMAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { if sigv4auth.IsQueryAuth(ctx) { - return parseIAMQueryAuth(ctx) + return parseIAMQueryAuth(ctx, expectedService) } if sigv4auth.IsQueryAuthV2(ctx) { return sigv4auth.AuthData{}, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrUnsupportedSignatureVersion) } - return parseIAMHeaderAuth(ctx) + return parseIAMHeaderAuth(ctx, expectedService) } -func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) { +func parseIAMHeaderAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { authData := sigv4auth.AuthData{} authorization := ctx.Get("Authorization") @@ -106,9 +295,9 @@ func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, err return authData, time.Time{}, false, err } - authData, err = sigv4auth.ParseAuthorization(authorization, sigv4auth.ServiceIAM) + authData, err = sigv4auth.ParseAuthorization(authorization, expectedService) if err != nil { - return authData, time.Time{}, false, mapIAMSigV4Error(err, authorization) + return authData, time.Time{}, false, mapIAMSigV4Error(err, expectedService, authorization) } if authData.Region != SigningRegion { @@ -121,17 +310,25 @@ func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, err 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}) - } - +// parseIAMQueryAuth parses SigV4 query-string (presigned URL) authentication +// parameters. Unlike S3 (see s3api/utils/presign-auth-reader.go), IAM/STS +// query-auth does not use X-Amz-Expires at all: confirmed live (niksis02 +// profile) against real IAM's ListUsers — a presigned request with +// X-Amz-Expires omitted, non-numeric ("abc"), negative ("-5"), or far +// beyond the 604800-second S3 maximum ("9999999") is accepted every time, +// while a request merely signed too long ago is rejected with +// SignatureDoesNotMatch ("Signature expired: ... is now earlier than ... +// (... - 15 min.)") — byte-for-byte the same message this codebase's own +// SignatureDoesNotMatchExpired already produces. So X-Amz-Expires is +// neither required nor validated here, and the same fixed ±timeExpiration +// freshness window header auth uses applies to query auth too. +func parseIAMQueryAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { authData, details, err := sigv4auth.ParseQueryAuthorization(ctx, sigv4auth.QueryAuthOptions{ - Service: sigv4auth.ServiceIAM, + Service: expectedService, Region: SigningRegion, }) if err != nil { - return authData, time.Time{}, true, mapIAMSigV4Error(err) + return authData, time.Time{}, true, mapIAMSigV4Error(err, expectedService) } if err := ValidateDateAt(details.SigningTime, time.Now().UTC()); err != nil { return authData, time.Time{}, true, err @@ -165,7 +362,7 @@ func ValidateDateAt(date, now time.Time) error { return nil } -func mapIAMSigV4Error(err error, authorization ...string) error { +func mapIAMSigV4Error(err error, expectedService string, authorization ...string) error { var queryErr *sigv4auth.QueryError if errors.As(err, &queryErr) { return mapIAMQueryError(queryErr) @@ -177,7 +374,7 @@ func mapIAMSigV4Error(err error, authorization ...string) error { if len(authorization) > 0 { authHeader = authorization[0] } - return mapIAMParseError(parseErr, authHeader) + return mapIAMParseError(parseErr, expectedService, authHeader) } var headersErr *sigv4auth.HeadersNotSignedError @@ -222,7 +419,7 @@ func mapIAMQueryError(err *sigv4auth.QueryError) error { } } -func mapIAMParseError(err *sigv4auth.ParseError, authorization string) error { +func mapIAMParseError(err *sigv4auth.ParseError, expectedService, authorization string) error { if authorization == "" { authorization = err.Input } @@ -247,7 +444,7 @@ func mapIAMParseError(err *sigv4auth.ParseError, authorization string) error { case sigv4auth.ErrMalformedCredential: return iamerr.IncompleteSignatureMalformedCredential(err.Input) case sigv4auth.ErrIncorrectService: - return iamerr.GetAPIError(iamerr.ErrIncorrectService) + return iamerr.IncorrectServiceScope(expectedService) case sigv4auth.ErrIncorrectTerminal: return iamerr.GetAPIError(iamerr.ErrInvalidTerminal) case sigv4auth.ErrInvalidDateFormat: diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go new file mode 100644 index 00000000..855753fb --- /dev/null +++ b/iamapi/internal/iammiddleware/policy.go @@ -0,0 +1,403 @@ +// 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 ( + "strconv" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/policy" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/httpctx" +) + +// iamActionPrefix is the policy-action vendor prefix for every action this +// middleware evaluates. It's only ever wired into the "iam" service +// pipeline — GetCallerIdentity and AssumeRoleWithWebIdentity +// (the two "sts" actions sharing this endpoint) never reach it, matching +// real AWS where sts:GetCallerIdentity requires no identity-based policy +// grant at all and AssumeRoleWithWebIdentity has no identity yet to check. +const iamActionPrefix = "iam:" + +// VerifyIAMPolicy authorizes an IAM action against the caller identity +// VerifyIAMAuth already resolved and stored via +// httpctx.ContextKeyCallerIdentity. Root bypasses this entirely. +// A long-term user is authorized by its own inline policies. +// A session is authorized by its assumed role's inline policies, +// additionally filtered by its own session policy if one was supplied — the +// session policy can only narrow, never widen, what the role otherwise +// allows: Effective permissions = Role identity-based permissions ∩ Session +// policy permissions. +// +// Authorization is evaluated as a full request context — action, resource, +// and condition — rather than action alone: store resolves the actual +// target resource's ARN (for actions naming an existing user/role/OIDC +// provider) so a Resource-scoped statement only grants what it names, and +// requestConditionContext supplies the request's aws:SourceIp/aws:username/ +// aws:PrincipalArn/aws:CurrentTime/aws:EpochTime values for a statement's +// Condition block. +func VerifyIAMPolicy(store IdentityStore) fiber.Handler { + return func(ctx fiber.Ctx) error { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + if identity.IsRoot { + return nil + } + + action, _ := iamutil.RequestParam(ctx, "Action") + fullAction := iamActionPrefix + action + + resourceArn, resourceTags := resourceForAction(ctx, store, action) + reqCtx := policy.RequestContext{ + Action: fullAction, + Resource: resourceArn, + Condition: requestConditionContext(ctx, identity, action, resourceTags), + } + + if !authorizeRequest(identity, reqCtx) { + return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + } + + // A rename/path-move is a two-resource transition: AWS's UpdateUser + // docs require permission on both the source object (checked above, + // via UserName) and the target object the user is being moved to. + if action == "UpdateUser" { + if target := updateUserTargetResource(ctx, store); target != "" { + targetCtx := reqCtx + targetCtx.Resource = target + if !authorizeRequest(identity, targetCtx) { + return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + } + } + } + + return nil + } +} + +// authorizeRequest reports whether reqCtx is allowed by identity's own +// inline policies and, for a session with a session policy attached, the +// narrowing session policy as well. +func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) bool { + if !policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx) { + return false + } + if identity.Session != nil && identity.SessionPolicy != "" { + sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}} + if !policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx) { + return false + } + } + return true +} + +// resourceForAction resolves the ARN action targets and, when that ARN names +// an existing resource, the tags currently stored on it +// — matching AWS's resource-type classification for each IAM API: a List +// action (or any action this doesn't specifically recognize) has no +// resource-level permissions and always evaluates against "*"; an action +// creating a new user/role/OIDC provider evaluates against the +// about-to-be-created resource's ARN, built from the request's own +// Path/Name parameters exactly as the corresponding controller method +// builds it, with no tags (the resource doesn't exist yet — aws:RequestTag +// is the applicable key for a Create action, see addRequestTagContext); an +// action naming an existing user/role by name evaluates against that +// entity's real, currently-stored Arn and Tags (resolved via store, since a +// custom Path means the caller-supplied name alone doesn't determine the +// ARN); an OIDC provider action already carries the exact target ARN as a +// request parameter, and its Tags are resolved via a single store lookup +// alongside it. +// +// A lookup failure (unknown name, or the request simply omits it) resolves +// to ("", nil), which only a wildcard Resource statement matches — the +// request still reaches the controller afterward, which reports the +// specific NoSuchEntity/MissingValue error if authorization happens to pass +// on a wildcard grant, or AccessDenied first if it doesn't. +func resourceForAction(ctx fiber.Ctx, store IdentityStore, action string) (string, []types.Tag) { + switch action { + case "CreateUser": + return newUserResource(ctx), nil + case "GetUser": + return getUserResource(ctx, store) + case "DeleteUser", "UpdateUser", "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey", + "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies": + return existingUserResource(ctx, store) + case "GetAccessKeyLastUsed": + return accessKeyOwnerResource(ctx, store) + case "CreateRole": + return newRoleResource(ctx), nil + case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies": + return existingRoleResource(ctx, store) + case "CreateOpenIDConnectProvider": + return newOIDCProviderResource(ctx), nil + case "GetOpenIDConnectProvider", "DeleteOpenIDConnectProvider", "AddClientIDToOpenIDConnectProvider", + "RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint": + arn, _ := iamutil.RequestParam(ctx, "OpenIDConnectProviderArn") + if arn == "" { + return "", nil + } + provider, err := store.GetOIDCProvider(ctx.Context(), arn) + if err != nil { + return arn, nil + } + return arn, provider.Tags + default: + return "*", nil + } +} + +func newUserResource(ctx fiber.Ctx) string { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "*" + } + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + return iamutil.BuildUserArn(iamutil.DefaultAccountID, path, userName) +} + +// existingUserResource resolves UserName to its stored Arn and Tags. An +// empty UserName resolves to ("", nil), the same lookup-failure fallback +// used elsewhere — none of this group's actions actually accept an omitted +// UserName (the controller layer requires it), so this only guards against +// a malformed request reaching here. +func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "", nil + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// getUserResource resolves GetUser's target: the named user's stored Arn and +// Tags, or — when UserName is omitted, matching the controller's (and real +// IAM's) "look up the caller's own identity" behavior — the calling user's +// own Arn and Tags. A session (assumed role) has no self IAM user to +// resolve, so it falls back to ("", nil), the same lookup-failure fallback +// used elsewhere. +func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + if identity.User != nil { + return identity.User.Arn, identity.User.Tags + } + return "", nil + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// accessKeyOwnerResource resolves GetAccessKeyLastUsed's target: unlike the +// rest of this group, the request carries no UserName at all, only the +// AccessKeyId being queried, so the resource-level check is against the IAM +// user that owns that key, matching real IAM's resource-type classification +// for this action. +func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + return "", nil + } + user, err := store.GetUserByAccessKeyID(ctx.Context(), accessKeyID) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// updateUserTargetResource resolves the destination ARN an UpdateUser +// request would relocate UserName to, so the caller for a rename/path-move +// can be required to hold permission on the target object as well as the +// source (matching the UpdateUser API's documented requirement). It returns +// "" when the request doesn't actually relocate the user (neither NewPath +// nor NewUserName supplied) or when the source user can't be resolved, the +// same fallback used elsewhere when a lookup fails. +func updateUserTargetResource(ctx fiber.Ctx, store IdentityStore) string { + newPath, _ := iamutil.RequestParam(ctx, "NewPath") + newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") + if newPath == "" && newUserName == "" { + return "" + } + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "" + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "" + } + finalPath := user.Path + if newPath != "" { + finalPath = newPath + } + finalUserName := user.UserName + if newUserName != "" { + finalUserName = newUserName + } + return iamutil.BuildUserArn(iamutil.DefaultAccountID, finalPath, finalUserName) +} + +func newRoleResource(ctx fiber.Ctx) string { + roleName, ok := iamutil.RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + return "*" + } + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + return iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName) +} + +func existingRoleResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + roleName, ok := iamutil.RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + return "*", nil + } + role, err := store.GetRole(ctx.Context(), roleName) + if err != nil { + return "", nil + } + return role.Arn, role.Tags +} + +func newOIDCProviderResource(ctx fiber.Ctx) string { + rawURL, ok := iamutil.RequestParam(ctx, "Url") + if !ok || rawURL == "" { + return "*" + } + url, err := iamutil.ValidateOIDCProviderURL(rawURL) + if err != nil { + return "" + } + return iamutil.BuildOIDCProviderArn(iamutil.DefaultAccountID, url) +} + +// requestConditionContext builds the "aws:"-keyed context a +// statement's Condition block is evaluated against: aws:CurrentTime and +// aws:EpochTime (the request's evaluation time, always available - needed +// for Date/Numeric time-based conditions to be usable at all), aws:SourceIp +// (the caller's address), aws:SecureTransport (whether the connection is +// TLS - AWS documents this key as present on every request, not just TLS +// ones), and — for a non-root identity — aws:PrincipalArn, aws:PrincipalAccount +// (this gateway is single-account, so it's always DefaultAccountID), and +// aws:userid together with, for a long-term user only, aws:username (AWS +// sets both simultaneously for an IAM user principal; a session has no +// aws:username, only aws:userid in IAM's own ":" +// form). For the three actions that accept a Tags parameter at creation +// time, aws:RequestTag/ (one per supplied tag) and aws:TagKeys (every +// supplied key) are populated the same way the controller itself parses +// Tags, so a tag-scoped Condition is enforceable against the resource about +// to be created. +// +// resourceTags are the tags currently stored on the resource +// resourceForAction resolved, if any — populated as both iam:ResourceTag/ +// (IAM's own documented resource-tag key) and aws:ResourceTag/ (the +// generic cross-service key AWS also exposes for a tagged resource), so a +// Condition written against either form sees the resource's real tags +// instead of always evaluating as absent. aws:PrincipalTag/ is +// populated from the caller's own tags: the User's, for a long-term user, or +// the assumed Role's, for a session (AWS's own behavior when no session +// tags were supplied at AssumeRole time — this gateway has no session-tag +// parameter, so the role's tags are the session's tags for its whole +// lifetime). +func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action string, resourceTags []types.Tag) map[string][]string { + condCtx := map[string][]string{} + now := time.Now().UTC() + condCtx["aws:CurrentTime"] = []string{now.Format(time.RFC3339)} + condCtx["aws:EpochTime"] = []string{strconv.FormatInt(now.Unix(), 10)} + condCtx["aws:SecureTransport"] = []string{strconv.FormatBool(ctx.Secure())} + if ip := ctx.IP(); ip != "" { + condCtx["aws:SourceIp"] = []string{ip} + } + if arn := callerArn(identity); arn != "" { + condCtx["aws:PrincipalArn"] = []string{arn} + condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID} + } + switch { + case identity.User != nil: + condCtx["aws:username"] = []string{identity.User.UserName} + condCtx["aws:userid"] = []string{identity.User.UserID} + addPrincipalTagContext(condCtx, identity.User.Tags) + case identity.Session != nil: + condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName} + if identity.Role != nil { + addPrincipalTagContext(condCtx, identity.Role.Tags) + } + } + + for _, tag := range resourceTags { + condCtx["iam:ResourceTag/"+tag.Key] = []string{tag.Value} + condCtx["aws:ResourceTag/"+tag.Key] = []string{tag.Value} + } + + switch action { + case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider": + addRequestTagContext(condCtx, ctx) + } + + return condCtx +} + +// addPrincipalTagContext populates aws:PrincipalTag/ from tags, the +// calling principal's own tags. +func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) { + for _, tag := range tags { + condCtx["aws:PrincipalTag/"+tag.Key] = []string{tag.Value} + } +} + +// addRequestTagContext populates aws:RequestTag/ and aws:TagKeys from +// the request's Tags parameter, parsed the same way the controller parses it +// for the actual create call. A parse failure (e.g. a malformed tag) is left +// unpopulated rather than surfaced here — the controller performs the same +// parse independently and will reject the request with the specific +// tag-validation error afterward, so no create can succeed with tags that +// silently evaded a tag-scoped Condition. +func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { + tags, err := iamutil.ParseTags(ctx) + if err != nil || len(tags) == 0 { + return + } + keys := make([]string, 0, len(tags)) + for _, tag := range tags { + condCtx["aws:RequestTag/"+tag.Key] = []string{tag.Value} + keys = append(keys, tag.Key) + } + condCtx["aws:TagKeys"] = keys +} + +// callerArn identifies identity the way real IAM error messages do: the +// user's own Arn, or the assumed-role session Arn. +func callerArn(identity types.Identity) string { + if identity.Session != nil { + return iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, identity.Session.RoleName, identity.Session.RoleSessionName) + } + if identity.User != nil { + return identity.User.Arn + } + return "" +} diff --git a/iamapi/internal/iamutil/access_key.go b/iamapi/internal/iamutil/access_key.go index 70457c4b..a60320db 100644 --- a/iamapi/internal/iamutil/access_key.go +++ b/iamapi/internal/iamutil/access_key.go @@ -18,6 +18,7 @@ import ( "crypto/rand" "encoding/base64" "regexp" + "strings" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" @@ -32,6 +33,12 @@ const ( minAccessKeyIDLen = 16 maxAccessKeyIDLen = 128 secretAccessKeyBytes = 30 + + // tempAccessKeyIDPrefix marks temporary credentials minted by + // AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that + // distinguishes them from long-term AKIA… access keys. + tempAccessKeyIDPrefix = "ASIA" + sessionTokenBytes = 128 ) var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`) @@ -58,6 +65,39 @@ func GenerateSecretAccessKey() (string, error) { return base64.StdEncoding.EncodeToString(b), nil } +// GenerateTempAccessKeyID returns a new cryptographically random temporary +// access key id in the ASIA… format, for credentials minted by +// AssumeRoleWithWebIdentity. +func GenerateTempAccessKeyID() (string, error) { + id, err := generateAWSID(tempAccessKeyIDPrefix, accessKeyIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate temporary IAM access key id: %v", err) + return "", err + } + return id, nil +} + +// GenerateSessionToken returns a new cryptographically random opaque +// session token for temporary credentials. Unlike AWS's own STS, whose +// session token self-encodes the session (so any STS host can validate it +// without shared state), this gateway looks the token up in its own +// session store, so an opaque random value is sufficient. +func GenerateSessionToken() (string, error) { + b := make([]byte, sessionTokenBytes) + if _, err := rand.Read(b); err != nil { + debuglogger.Logf("failed to generate IAM session token: %v", err) + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// IsTempAccessKeyID reports whether accessKeyID has the ASIA… prefix used +// for temporary credentials minted by AssumeRoleWithWebIdentity, as opposed +// to a long-term AKIA… access key. +func IsTempAccessKeyID(accessKeyID string) bool { + return strings.HasPrefix(accessKeyID, tempAccessKeyIDPrefix) +} + // ValidateAccessKeyID checks that accessKeyID fits within the allowed length // range and character set. func ValidateAccessKeyID(accessKeyID string) error { diff --git a/iamapi/internal/iamutil/oidc_thumbprint.go b/iamapi/internal/iamutil/oidc_thumbprint.go index 11ff9881..ab8dacc2 100644 --- a/iamapi/internal/iamutil/oidc_thumbprint.go +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -32,11 +32,12 @@ import ( const oidcThumbprintFetchTimeout = 8 * time.Second // FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch -// behavior: it opens a raw TLS handshake (crypto/tls, not a full -// HTTP GET) to host:443, where host is derived from providerURL (a -// scheme-stripped OIDC provider Url), and returns the SHA-1 thumbprint of -// the last (top-most/intermediate CA) certificate in the peer's presented -// chain. +// behavior: it opens a TLS handshake (crypto/tls, not a full HTTP GET) to +// host:443, where host is derived from providerURL (a scheme-stripped OIDC +// provider Url), verifying the presented chain against the system trust +// store and the provider's own hostname like any normal TLS client, and +// returns the SHA-1 thumbprint of the last (top-most/intermediate CA) +// certificate in the peer's presented chain. // // SSRF hardening (mandatory): the hostname is resolved once via // net.DefaultResolver.LookupIP; if any resolved address is @@ -47,13 +48,21 @@ const oidcThumbprintFetchTimeout = 8 * time.Second // time, closing the DNS-rebinding TOCTOU gap) while presenting the original // hostname via tls.Config.ServerName for SNI/certificate purposes. // -// tls.Config.InsecureSkipVerify is deliberately set: this handshake exists -// solely to observe whatever certificate chain the peer presents — that is -// the entire point of AWS's thumbprint-pinning feature (trusting an -// operator-established fingerprint for IDPs whose certs may not pass -// standard verification). No application data is sent or received over -// this connection, so skipping chain verification does not expose any real -// traffic to a MITM. +// Verification is deliberately NOT skipped here: unlike a one-shot +// connection whose result is used and discarded, the certificate observed +// during this handshake is persisted as a long-lived trust anchor, compared +// against every future JWKS fetch for this provider. An unauthenticated +// handshake would let an active network/DNS attacker present any chain they +// control at enrollment time and have it pinned as trusted, then later +// present a matching leaf issued by that same chain — with attacker-chosen +// signing keys — to any subsequent (equally unauthenticated) JWKS fetch. A +// provider whose certificate doesn't chain to a system-trusted root (e.g. a +// private/self-hosted IdP on an internal CA) simply can't use auto-fetch: +// the caller gets an error and must supply ThumbprintList explicitly, having +// obtained the fingerprint through some independently verified channel — +// the same operational shape WithOIDCThumbprintAutoFetchDisabled already +// provides unconditionally, scoped here to just the providers that fail +// public verification. func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { host := hostFromOIDCUrl(providerURL) displayURL := "https://" + providerURL @@ -73,25 +82,38 @@ func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { } } - dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} - conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), "443")) + thumbprint, err := dialAndVerifyThumbprint(ctx, net.JoinHostPort(ips[0].String(), "443"), host, nil) if err != nil { - debuglogger.Logf("oidc thumbprint fetch: tls dial failed for %q (%s): %v", host, ips[0], err) + debuglogger.Logf("oidc thumbprint fetch: tls dial/verify failed for %q (%s): %v — supply ThumbprintList explicitly for providers that fail public CA verification", host, ips[0], err) return "", iamerr.OpenIdIdpCommunicationError(displayURL) } + debuglogger.Logf("oidc thumbprint fetch: verified %q via system trust store, computed thumbprint %s", displayURL, thumbprint) + return thumbprint, nil +} + +// dialAndVerifyThumbprint dials addr over TLS, presenting host via SNI and +// verifying the peer's certificate against roots (nil selects the host +// system's trust store, FetchThumbprint's real usage), then returns +// ThumbprintFromChain's result for the now-verified presented chain. Split +// out from FetchThumbprint so the verification behavior itself is +// unit-testable with an explicit root pool — the same rationale as +// ThumbprintFromChain's own split, and for the same reason: FetchThumbprint's +// SSRF guard must always reject loopback targets, so it can never itself be +// exercised against a same-process test server. +func dialAndVerifyThumbprint(ctx context.Context, addr, host string, roots *x509.CertPool) (string, error) { + dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, RootCAs: roots}} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return "", err + } defer conn.Close() tlsConn, ok := conn.(*tls.Conn) if !ok { - return "", iamerr.OpenIdIdpCommunicationError(displayURL) + return "", errors.New("iamutil: non-TLS connection") } - thumbprint, err := ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) - if err != nil { - debuglogger.Logf("oidc thumbprint fetch: %v", err) - return "", iamerr.OpenIdIdpCommunicationError(displayURL) - } - return thumbprint, nil + return ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) } // ThumbprintFromChain computes AWS's documented OIDC thumbprint: the SHA-1 diff --git a/iamapi/internal/iamutil/oidc_thumbprint_test.go b/iamapi/internal/iamutil/oidc_thumbprint_test.go index 39d65a9c..66fc72fd 100644 --- a/iamapi/internal/iamutil/oidc_thumbprint_test.go +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -18,6 +18,7 @@ import ( "context" "crypto/sha1" "crypto/tls" + "crypto/x509" "encoding/hex" "net" "net/http/httptest" @@ -69,11 +70,56 @@ func TestThumbprintFromChainEmptyChain(t *testing.T) { } } +// TestDialAndVerifyThumbprintRejectsUntrustedCert verifies that +// dialAndVerifyThumbprint rejects a certificate that doesn't chain to a +// trusted root, rather than trusting whatever the peer presents — trusting +// any presented chain is exactly what would let an active network/DNS +// attacker at enrollment time have their own chain pinned as the provider's +// permanent trust anchor. A self-signed test server's certificate, which +// chains to nothing any real trust store recognizes, must be rejected +// instead of silently hashed. +func TestDialAndVerifyThumbprintRejectsUntrustedCert(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + // roots=nil selects the host system's real trust store, the same as + // FetchThumbprint's actual usage - httptest's self-signed certificate + // must not verify against it. + if _, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", nil); err == nil { + t.Fatal("dialAndVerifyThumbprint: expected verification error for untrusted self-signed certificate, got nil") + } +} + +// TestDialAndVerifyThumbprintAcceptsVerifiedCert is the positive +// counterpart: once the peer's certificate does verify (here, against an +// explicit pool containing the test server's own certificate, standing in +// for a real public CA in FetchThumbprint's system-trust-store case), +// auto-fetch must still succeed and compute the same thumbprint +// TestThumbprintFromChain gets by hashing the chain directly - proving the +// stricter check rejects only genuinely untrusted chains, not every chain. +func TestDialAndVerifyThumbprintAcceptsVerifiedCert(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + + got, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", roots) + if err != nil { + t.Fatalf("dialAndVerifyThumbprint: %v", err) + } + + sum := sha1.Sum(srv.Certificate().Raw) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Fatalf("dialAndVerifyThumbprint thumbprint = %q, want %q", got, want) + } +} + // TestFetchThumbprintSSRFGuard confirms FetchThumbprint refuses to dial -// loopback/private targets before any network attempt, matching the -// mandatory SSRF hardening design: 127.0.0.1 is exactly the kind of -// address a malicious CreateOpenIDConnectProvider caller could supply to -// probe the gateway's own local network. +// loopback/private targets before any network attempt: 127.0.0.1 is exactly +// the kind of address a malicious CreateOpenIDConnectProvider caller could +// supply to probe the gateway's own local network. func TestFetchThumbprintSSRFGuard(t *testing.T) { tests := []string{ "127.0.0.1", diff --git a/iamapi/internal/iamutil/request_test.go b/iamapi/internal/iamutil/request_test.go index cce8c688..538fc227 100644 --- a/iamapi/internal/iamutil/request_test.go +++ b/iamapi/internal/iamutil/request_test.go @@ -72,3 +72,54 @@ func TestMatchQueryOrFormArgs(t *testing.T) { }) } } + +func TestHasRequestParamPrefix(t *testing.T) { + tests := []struct { + name string + method string + target string + body string + contentType string + want bool + }{ + {name: "query, member 1", method: http.MethodGet, target: "/any?PolicyArns.member.1.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, member 10", method: http.MethodGet, target: "/any?PolicyArns.member.10.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, index gap (member 3 only)", method: http.MethodGet, target: "/any?PolicyArns.member.3.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, empty-but-present value", method: http.MethodGet, target: "/any?PolicyArns.member.1.arn=", want: true}, + {name: "form, member 2", method: http.MethodPost, target: "/any", body: "PolicyArns.member.2.arn=arn:aws:iam::000000000000:policy/p", contentType: fiber.MIMEApplicationForm, want: true}, + {name: "absent", method: http.MethodGet, target: "/any?Action=AssumeRoleWithWebIdentity", want: false}, + {name: "unrelated prefix untouched", method: http.MethodGet, target: "/any?PolicyArnsSomethingElse=x", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := fiber.New() + app.Add([]string{http.MethodGet, http.MethodPost}, "/*", func(ctx fiber.Ctx) error { + if HasRequestParamPrefix(ctx, "PolicyArns.member.") { + return ctx.SendString("found") + } + return ctx.SendString("absent") + }) + + 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) + } + want := "absent" + if tt.want { + want = "found" + } + if string(body) != want { + t.Fatalf("HasRequestParamPrefix result = %q, want %q", string(body), want) + } + }) + } +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 24a91189..e1c2d981 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -73,6 +73,27 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) { return "", false } +// HasRequestParamPrefix reports whether any query or form parameter key +// (regardless of its value, including empty) starts with prefix. Unlike +// RequestParam, which probes one exact name, this scans every key actually +// present — needed to reject an AWS Query-protocol indexed-list parameter +// (e.g. "PolicyArns.member.N.arn") for every N a caller might supply, +// instead of only a fixed index like ".1.", which a caller could bypass +// entirely by supplying a different index, a gap, or several members. +func HasRequestParamPrefix(ctx fiber.Ctx, prefix string) bool { + for key := range ctx.Request().URI().QueryArgs().All() { + if strings.HasPrefix(string(key), prefix) { + return true + } + } + for key := range ctx.Request().PostArgs().All() { + if strings.HasPrefix(string(key), prefix) { + return true + } + } + return false +} + // GetUserName resolves the UserName request parameter and validates it // against maxLen, returning missingErr if the parameter is absent or empty. // operation is included in the debug log on failure (e.g. "DeleteUser"). diff --git a/iamapi/internal/iamutil/webidentity.go b/iamapi/internal/iamutil/webidentity.go new file mode 100644 index 00000000..9be96f66 --- /dev/null +++ b/iamapi/internal/iamutil/webidentity.go @@ -0,0 +1,887 @@ +// 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 ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" + "golang.org/x/sync/singleflight" +) + +const ( + MinRoleSessionNameLen = 2 + MaxRoleSessionNameLen = 64 + + MinWebIdentityTokenLen = 4 + MaxWebIdentityTokenLen = 20000 + + MinRoleArnLen = 20 + MaxRoleArnLen = 2048 + + MinDurationSeconds = 900 + MaxDurationSeconds = 43200 + DefaultDurationSeconds = 3600 + + // webIdentityExpLeeway is AWS's observed clock-skew allowance for a web + // identity token's exp claim: a token expired by less than this is + // still accepted. + webIdentityExpLeeway = 5 * time.Minute + + oidcFetchTimeout = 8 * time.Second + maxOIDCFetchBodyBytes = 1 << 20 // 1 MiB; well beyond any real discovery doc or JWKS. + + // maxJWKSKeysPerType is AWS's documented OIDC provider JWKS limit: at + // most 100 RSA and 100 EC keys. A JWKS response exceeding either bound + // is rejected outright rather than accepted into the cache and iterated + // over on every verification. + maxJWKSKeysPerType = 100 + + // jwksMinForcedRefreshInterval rate-limits how often a token with an + // unrecognized kid can force a JWKS refresh for the same issuer, on top + // of jwksCacheTTL's normal expiry. Without this, anyone who knows a + // trusted issuer/audience/role ARN could send unlimited tokens carrying + // unique, made-up kid values and force a fresh discovery-document-plus- + // JWKS fetch against the real IdP for every single one, before any + // signature or authentication check ever runs. + jwksMinForcedRefreshInterval = 30 * time.Second + + // maxOIDCFetchRedirects bounds how many redirects a discovery-document + // or JWKS fetch will follow. net/http's own default client stops after + // 10 redirects, but that default is implemented by its CheckRedirect + // func - replacing CheckRedirect (as ssrfSafeHTTPClient does, to add the + // https-only and SSRF checks) silently loses that cap entirely unless + // the replacement enforces its own. + maxOIDCFetchRedirects = 5 +) + +var roleSessionNamePattern = regexp.MustCompile(`^[\w+=,.@-]*$`) + +// ValidateRoleSessionName checks RoleSessionName against STS's length and +// charset constraints. +func ValidateRoleSessionName(name string) error { + if len(name) < MinRoleSessionNameLen { + debuglogger.Logf("RoleSessionName too short: %q", name) + return iamerr.ValueTooShort("roleSessionName", MinRoleSessionNameLen) + } + if len(name) > MaxRoleSessionNameLen { + debuglogger.Logf("RoleSessionName too long: %q", name) + return iamerr.ValueTooLong("roleSessionName", MaxRoleSessionNameLen) + } + if !roleSessionNamePattern.MatchString(name) { + debuglogger.Logf("invalid RoleSessionName characters: %q", name) + return iamerr.InvalidRoleSessionName(name) + } + return nil +} + +// ValidateWebIdentityTokenLength checks WebIdentityToken against STS's +// length constraints (content/structure is validated separately by +// ParseWebIdentityClaims). +func ValidateWebIdentityTokenLength(token string) error { + if len(token) < MinWebIdentityTokenLen { + debuglogger.Logf("WebIdentityToken too short: length=%d", len(token)) + return iamerr.ValueTooShort("webIdentityToken", MinWebIdentityTokenLen) + } + if len(token) > MaxWebIdentityTokenLen { + debuglogger.Logf("WebIdentityToken too long: length=%d", len(token)) + return iamerr.ValueTooLong("webIdentityToken", MaxWebIdentityTokenLen) + } + return nil +} + +// ValidateRoleArnLength checks RoleArn against STS's length constraints. +func ValidateRoleArnLength(arn string) error { + if len(arn) < MinRoleArnLen { + debuglogger.Logf("RoleArn too short: %q", arn) + return iamerr.ValueTooShort("roleArn", MinRoleArnLen) + } + if len(arn) > MaxRoleArnLen { + debuglogger.Logf("RoleArn too long: length=%d", len(arn)) + return iamerr.ValueTooLong("roleArn", MaxRoleArnLen) + } + return nil +} + +// ParseDurationSeconds parses AssumeRoleWithWebIdentity's optional +// DurationSeconds request parameter, returning DefaultDurationSeconds +// (always 1 hour, regardless of the role's own MaxSessionDuration) when +// absent. +func ParseDurationSeconds(ctx fiber.Ctx) (int32, error) { + raw, ok := RequestParam(ctx, "DurationSeconds") + if !ok || raw == "" { + return DefaultDurationSeconds, nil + } + + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + debuglogger.Logf("malformed DurationSeconds value %q", raw) + return 0, iamerr.MalformedInput() + } + if parsed < MinDurationSeconds { + debuglogger.Logf("DurationSeconds too low: %s", raw) + return 0, iamerr.DurationSecondsTooLow(raw) + } + if parsed > MaxDurationSeconds { + debuglogger.Logf("DurationSeconds too high: %s", raw) + return 0, iamerr.DurationSecondsTooHigh(raw) + } + + return int32(parsed), nil +} + +// RoleNameFromAssumeArn extracts the role name from a RoleArn of the shape +// arn:aws:iam:::role/, for an assumed-role account +// matching accountID. Any other shape (wrong account, wrong resource type, +// not even ARN-shaped) reports ok=false: AssumeRoleWithWebIdentity treats +// all such cases identically (AccessDenied), never distinguishing "no such +// role" from "malformed ARN" the way other IAM actions do, so no error +// value is returned here. +func RoleNameFromAssumeArn(arn, accountID string) (roleName string, ok bool) { + const prefix = "arn:aws:iam::" + if !strings.HasPrefix(arn, prefix) { + return "", false + } + rest := strings.TrimPrefix(arn, prefix) + + acct, rest, found := strings.Cut(rest, ":") + if !found || acct != accountID { + return "", false + } + + resourceType, resource, found := strings.Cut(rest, "/") + if !found || resourceType != "role" || resource == "" { + return "", false + } + + if idx := strings.LastIndex(resource, "/"); idx >= 0 { + resource = resource[idx+1:] + } + if resource == "" { + return "", false + } + return resource, true +} + +// ParseWebIdentityClaims parses tokenString as a JWT without verifying its +// signature, returning its claims. This is the first step of +// AssumeRoleWithWebIdentity validation: the token's iss claim must be read +// before it's known which OIDC provider (and therefore which signing keys) +// to verify against. +func ParseWebIdentityClaims(tokenString string) (jwt.MapClaims, error) { + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + debuglogger.Logf("web identity token is not a valid JWT: %v", err) + return nil, iamerr.InvalidIdentityTokenMalformed() + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, iamerr.InvalidIdentityTokenMalformed() + } + return claims, nil +} + +// WebIdentityIssuer returns claims' iss value, scheme-stripped to match the +// stored form of a registered OIDC provider's Url. +// +// Only an "https://" prefix is stripped — OIDC issuer identifiers are +// compared exactly, scheme included, and CreateOpenIDConnectProvider already +// requires every registered provider's Url to be https. An iss using any +// other scheme (or none at all) therefore can never legitimately equal a +// registered provider; returning it unstripped in that case (rather than +// also trimming a bare "http://") guarantees it stays distinguishable from a +// same-host https issuer instead of being silently treated as equivalent. +func WebIdentityIssuer(claims jwt.MapClaims) (string, bool) { + iss, ok := claims["iss"].(string) + if !ok || iss == "" { + return "", false + } + if stripped, ok := strings.CutPrefix(iss, "https://"); ok { + return stripped, true + } + return iss, true +} + +// WebIdentityAudience resolves a web identity token's "effective audience" +// (the value AWS maps to the :aud trust-policy condition key) +// along with its original aud claim value(s) (mapped to :oaud +// whenever azp overrides them). +// +// Whenever azp (authorized party) is present, it is always the effective +// audience — regardless of whether aud itself carries one value or many — +// and the original aud claim value(s) are additionally returned for the +// oaud mapping; this matters for Google hybrid clients, where aud names the +// backend project and azp names the actual OAuth client that requested the +// token. A multi-valued aud with no azp is rejected — per OpenID Connect +// Core, a multi-audience ID token must carry azp to disambiguate which +// audience the token was issued for, and AWS enforces this as a hard +// requirement rather than a recommendation. +func WebIdentityAudience(claims jwt.MapClaims) (audience string, original []string, err error) { + var auds []string + switch v := claims["aud"].(type) { + case string: + if v != "" { + auds = []string{v} + } + case []any: + for _, e := range v { + if s, ok := e.(string); ok && s != "" { + auds = append(auds, s) + } + } + } + + if len(auds) == 0 { + debuglogger.Logf("web identity token has no aud claim") + return "", nil, iamerr.InvalidIdentityTokenClaims() + } + + if azp, _ := claims["azp"].(string); azp != "" { + return azp, auds, nil + } + + if len(auds) > 1 { + debuglogger.Logf("web identity token has multiple audiences %v but no azp claim", auds) + return "", nil, iamerr.InvalidIdentityTokenMultipleAudiences() + } + return auds[0], nil, nil +} + +// wellKnownClaims are excluded from ExtractClaimContext: they're either +// handled specially (iss/aud/azp/sub) or aren't meaningful as trust-policy +// Condition context (exp/iat/nbf are timestamps, not strings). +var wellKnownClaims = map[string]bool{ + "iss": true, "aud": true, "azp": true, "sub": true, + "exp": true, "iat": true, "nbf": true, +} + +// ExtractClaimContext projects every other top-level scalar or +// scalar-array claim from a web identity token into a plain map, for +// trust-policy Condition keys beyond the well-known "aud"/"sub" (e.g. a +// custom "amr" or "groups" claim, or a Bool/Numeric/Date condition against a +// custom "admin"/"tier"/"level" claim). +func ExtractClaimContext(claims jwt.MapClaims) map[string][]string { + out := make(map[string][]string, len(claims)) + for name, value := range claims { + if wellKnownClaims[name] { + continue + } + switch v := value.(type) { + case []any: + var values []string + for _, e := range v { + if s, ok := claimScalarString(e); ok { + values = append(values, s) + } + } + if len(values) > 0 { + out[name] = values + } + default: + if s, ok := claimScalarString(v); ok { + out[name] = []string{s} + } + } + } + return out +} + +// claimScalarString converts a single decoded JWT claim value to its +// Condition-context string form. golang-jwt decodes every JSON number as +// float64 and every JSON bool as bool (standard encoding/json behavior for +// an interface{} target) - without this, a claim like "tier": 3 or "admin": +// true would never reach the Condition context at all (the key would always +// look "absent"), silently defeating a Bool/Numeric/Date condition guarding +// it. 'f', -1 gives the shortest round-tripping decimal form (3.0 -> "3", +// 4.5 -> "4.5"), matching how a policy author would hand-write the value. +func claimScalarString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + case bool: + return strconv.FormatBool(v), true + default: + return "", false + } +} + +// BuildAssumedRoleArn constructs the ARN a role's temporary session +// credentials are identified by. Unlike the role's own ARN +// (arn:aws:iam::...:role/...), an assumed session uses the sts service. +func BuildAssumedRoleArn(accountID, roleName, roleSessionName string) string { + return fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountID, roleName, roleSessionName) +} + +// PackedPolicySize reports the percentage of policy.MaxSessionPolicyBytes +// sessionPolicy consumes, or nil if no session Policy parameter was +// supplied at all — matching how AWS omits PackedPolicySize entirely in +// that case rather than reporting 0%. +func PackedPolicySize(sessionPolicy string) *int64 { + if sessionPolicy == "" { + return nil + } + pct := int64(len(sessionPolicy) * 100 / policy.MaxSessionPolicyBytes) + return &pct +} + +// VerifyWebIdentityExpiration checks claims' exp against now, allowing +// webIdentityExpLeeway of clock skew. +func VerifyWebIdentityExpiration(claims jwt.MapClaims, now time.Time) error { + expFloat, ok := claims["exp"].(float64) + if !ok { + debuglogger.Logf("web identity token has no exp claim") + return iamerr.InvalidIdentityTokenClaims() + } + exp := int64(expFloat) + if now.After(time.Unix(exp, 0).Add(webIdentityExpLeeway)) { + debuglogger.Logf("web identity token expired: now=%d exp=%d", now.Unix(), exp) + return iamerr.ExpiredWebIdentityToken(now.Unix(), exp) + } + return nil +} + +// VerifyWebIdentityRequiredClaims checks claims for AWS's other mandatory +// web identity token claims beyond exp (already checked separately by +// VerifyWebIdentityExpiration): iat and sub must both be present, and nbf +// (if present) must not be in the future beyond webIdentityExpLeeway of +// clock skew. Confirmed against real AWS (niksis02 profile): a token with +// exp but no iat, or with iat but no sub, is rejected with +// InvalidIdentityToken "Missing a required claim: ." — without +// this check, such a token would otherwise obtain credentials whenever the +// role's trust policy doesn't itself require sub via Condition. +func VerifyWebIdentityRequiredClaims(claims jwt.MapClaims, now time.Time) error { + if _, ok := claims["iat"].(float64); !ok { + debuglogger.Logf("web identity token has no iat claim") + return iamerr.InvalidIdentityTokenMissingClaim("iat") + } + if sub, ok := claims["sub"].(string); !ok || sub == "" { + debuglogger.Logf("web identity token has no sub claim") + return iamerr.InvalidIdentityTokenMissingClaim("sub") + } + if nbfFloat, ok := claims["nbf"].(float64); ok { + nbf := time.Unix(int64(nbfFloat), 0) + if now.Before(nbf.Add(-webIdentityExpLeeway)) { + debuglogger.Logf("web identity token not yet valid: now=%d nbf=%d", now.Unix(), int64(nbfFloat)) + return iamerr.InvalidIdentityTokenClaims() + } + } + return nil +} + +// VerifyWebIdentitySignature fetches issuerURL's OIDC discovery document +// and JWKS (from cache when a fresh-enough entry exists), then verifies +// tokenString's signature against the matching key. On success it returns +// the token's verified claims (exp/nbf/iat are not re-checked here — +// callers that need those checks perform them separately with AWS-matching +// messages and leeway). +// +// thumbprints is the OIDC provider's registered ThumbprintList, used as a +// pinned-certificate fallback when the JWKS endpoint's TLS certificate +// doesn't chain to a trusted root (self-signed/private-CA providers). +// +// If the cached key set doesn't contain the token's kid, the cache is +// bypassed for one forced refresh before giving up — the provider may have +// rotated its signing key since the cache entry was fetched. +func VerifyWebIdentitySignature(ctx context.Context, tokenString, issuerURL string, thumbprints []string) (jwt.MapClaims, error) { + keys, err := cachedJWKS(ctx, issuerURL, thumbprints) + if err != nil { + debuglogger.Logf("failed to fetch JWKS for web identity provider %q: %v", issuerURL, err) + return nil, iamerr.InvalidIdentityTokenIDPCommunicationError() + } + + claims, err := verifySignatureWithKeys(tokenString, keys) + if err != nil && errors.Is(err, errUnknownKID) { + keys, refreshErr := forceRefreshJWKSCache(ctx, issuerURL, thumbprints) + if refreshErr != nil { + debuglogger.Logf("failed to refresh JWKS for web identity provider %q: %v", issuerURL, refreshErr) + return nil, iamerr.InvalidIdentityTokenIDPCommunicationError() + } + claims, err = verifySignatureWithKeys(tokenString, keys) + } + if err != nil { + debuglogger.Logf("web identity token signature verification failed: %v", err) + return nil, iamerr.InvalidIdentityTokenClaims() + } + return claims, nil +} + +// errUnknownKID is keyFunc's error when a token's kid names no key in the +// set — the signal VerifyWebIdentitySignature uses to force one cache +// refresh (the provider may have rotated its signing key) before giving up. +var errUnknownKID = errors.New("no matching JWKS key for kid") + +// verifySignatureWithKeys is VerifyWebIdentitySignature's network-free core, +// split out so it can be exercised directly against an in-memory key set +// (the SSRF guard in fetchJWKS's dialer means it can never itself be +// exercised against a same-process test server — the same split +// FetchThumbprint/ThumbprintFromChain use). The returned error is the raw +// parse/verification failure (not yet converted to an iamerr), so callers +// can distinguish errUnknownKID from every other failure. +func verifySignatureWithKeys(tokenString string, keys *jwkSet) (jwt.MapClaims, error) { + parser := jwt.NewParser( + jwt.WithoutClaimsValidation(), + jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}), + ) + token, err := parser.Parse(tokenString, keys.keyFunc) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("web identity token failed signature verification") + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("web identity token claims are not a JSON object") + } + return claims, nil +} + +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwkSet struct { + Keys []jwk `json:"keys"` +} + +// keyFunc resolves a token's verification key by matching its header kid +// against the set. A set with exactly one key is used regardless of kid +// (or its absence) — a common pattern for single-key providers. +func (s *jwkSet) keyFunc(token *jwt.Token) (any, error) { + kid, _ := token.Header["kid"].(string) + + if len(s.Keys) == 1 && (kid == "" || s.Keys[0].Kid == kid || s.Keys[0].Kid == "") { + return s.Keys[0].publicKey() + } + for _, k := range s.Keys { + if k.Kid == kid { + return k.publicKey() + } + } + return nil, fmt.Errorf("%w: %q", errUnknownKID, kid) +} + +func (k jwk) publicKey() (any, error) { + switch k.Kty { + case "RSA": + nb, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, fmt.Errorf("decode RSA modulus: %w", err) + } + eb, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("decode RSA exponent: %w", err) + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nb), + E: int(new(big.Int).SetBytes(eb).Int64()), + }, nil + case "EC": + var curve elliptic.Curve + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported EC curve %q", k.Crv) + } + xb, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + return nil, fmt.Errorf("decode EC x: %w", err) + } + yb, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + return nil, fmt.Errorf("decode EC y: %w", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xb), + Y: new(big.Int).SetBytes(yb), + }, nil + default: + return nil, fmt.Errorf("unsupported JWK key type %q", k.Kty) + } +} + +type oidcDiscoveryDoc struct { + Issuer string `json:"issuer"` + JWKSUri string `json:"jwks_uri"` +} + +// validateDiscoveryIssuer reports an error unless doc's issuer exactly +// matches issuerURL's provider Url: both the OIDC discovery spec and +// AWS's own documentation require an exact match, not merely a document +// reachable from the provider's own URL — otherwise a provider could return, +// or be redirected/misdirected to, an entirely different issuer's metadata. +func validateDiscoveryIssuer(doc oidcDiscoveryDoc, issuerURL string) error { + want := "https://" + issuerURL + if doc.Issuer != want { + return fmt.Errorf("discovery document for %q has mismatched issuer %q", issuerURL, doc.Issuer) + } + return nil +} + +// jwksCacheTTL bounds how long a fetched key set is reused before +// VerifyWebIdentitySignature fetches it again, so that a burst of +// AssumeRoleWithWebIdentity calls for the same provider doesn't turn into a +// discovery-document-plus-JWKS fetch per call (latency, rate-limiting, and — +// since this fetch happens before the caller is authenticated — anonymous +// request amplification against the IdP). +const jwksCacheTTL = 5 * time.Minute + +type jwksCacheEntry struct { + keys *jwkSet + expiresAt time.Time + // lastForcedRefresh is when an unknown-kid lookup last bypassed + // expiresAt to force a fetch for this issuer, gating + // jwksMinForcedRefreshInterval (see forceRefreshJWKSCache). + lastForcedRefresh time.Time +} + +var ( + jwksCacheMu sync.Mutex + jwksCache = map[string]jwksCacheEntry{} + + // jwksFetchGroup coalesces concurrent fetches for the same issuerURL — + // from cache-expiry and forced unknown-kid refreshes alike — into a + // single outbound discovery-document-plus-JWKS request, so a burst of + // simultaneous AssumeRoleWithWebIdentity calls (e.g. many callers' + // caches expiring at once) doesn't turn into one fetch per caller. + jwksFetchGroup singleflight.Group +) + +// jwksCacheKey builds cachedJWKS's cache key from issuerURL and the +// provider's current ThumbprintList, so that changing a provider's +// thumbprints (e.g. after a signing-key or CA compromise) or recreating the +// provider at the same URL with a different ThumbprintList invalidates any +// previously cached key set immediately instead of leaving it reachable for +// up to jwksCacheTTL more. Every call site always supplies the provider's +// current ThumbprintList (freshly read from storage for the request being +// verified), so a changed configuration always maps to a different key here; +// thumbprints are sorted first since storage doesn't guarantee list order is +// stable across reads of an unchanged provider. +func jwksCacheKey(issuerURL string, thumbprints []string) string { + sorted := slices.Clone(thumbprints) + slices.Sort(sorted) + return issuerURL + "|" + strings.Join(sorted, ",") +} + +// cachedJWKS returns issuerURL's key set from cache if a fresh-enough entry +// exists for the current thumbprints, otherwise fetches and caches a fresh +// one. +func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + jwksCacheMu.Unlock() + if ok && time.Now().Before(entry.expiresAt) { + return entry.keys, nil + } + return fetchAndCacheJWKS(ctx, issuerURL, thumbprints) +} + +// forceRefreshJWKSCache is VerifyWebIdentitySignature's fallback when a +// token's kid matches no cached key: the provider may have rotated its +// signing key since the cache entry was fetched. This bypasses +// expiresAt but not jwksMinForcedRefreshInterval — within that window of a +// previous forced refresh attempt for the same issuer, the still-cached (and +// still non-matching) key set is returned unchanged rather than fetching +// again. Without this gate, an unknown kid alone (no valid signature or +// authentication required to reach this code) would let anyone who knows a +// trusted issuer force one outbound fetch per token by simply varying kid. +// +// lastForcedRefresh is recorded *before* the fetch is attempted, not after a +// success: gating only on success left a failing or slow/unreachable +// issuer with no negative-caching at all — every unknown-kid token would +// re-trigger a fresh outbound fetch (and wait out its own timeout) with no +// backoff, since a failed attempt never set the timestamp that would have +// gated the next one. Recording the attempt up front bounds retries to one +// per jwksMinForcedRefreshInterval regardless of whether the fetch succeeds. +func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + if ok && time.Since(entry.lastForcedRefresh) < jwksMinForcedRefreshInterval { + jwksCacheMu.Unlock() + if entry.keys == nil { + // The gate is active but there's no key material to fall back + // on — either this is the very first forced refresh for key + // and it hasn't completed yet, or every attempt so far has + // failed. Fail closed instead of returning a nil key set for + // the caller to dereference. + return nil, fmt.Errorf("no cached JWKS available for %q and a recent refresh attempt is still rate-limited", issuerURL) + } + return entry.keys, nil + } + entry.lastForcedRefresh = time.Now() + jwksCache[key] = entry + jwksCacheMu.Unlock() + + return fetchAndCacheJWKS(ctx, issuerURL, thumbprints) +} + +// fetchAndCacheJWKS fetches issuerURL's key set and, on success, replaces +// its cache entry, coalescing concurrent callers for the same issuerURL AND +// thumbprints via jwksFetchGroup (keyed identically to jwksCache, so a +// caller mid-fetch for one thumbprint configuration never receives a result +// coalesced from a differently-configured concurrent caller). +func fetchAndCacheJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + v, err, _ := jwksFetchGroup.Do(key, func() (any, error) { + keys, err := fetchJWKS(ctx, issuerURL, thumbprints) + if err != nil { + return nil, err + } + jwksCacheMu.Lock() + entry := jwksCache[key] + entry.keys = keys + entry.expiresAt = time.Now().Add(jwksCacheTTL) + jwksCache[key] = entry + jwksCacheMu.Unlock() + return keys, nil + }) + if err != nil { + return nil, err + } + return v.(*jwkSet), nil +} + +// fetchJWKS retrieves issuerURL's OIDC discovery document, then the JWKS it +// points to. issuerURL is the provider's stored Url (scheme stripped). +// thumbprints, if non-empty, lets the fetch's TLS connections succeed +// against a self-signed/private-CA certificate whose chain matches one of +// them, the same trust-pinning fallback real AWS documents for OIDC +// providers. +func fetchJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + client := ssrfSafeHTTPClient(thumbprints) + base := "https://" + issuerURL + + var doc oidcDiscoveryDoc + if err := fetchJSON(ctx, client, strings.TrimRight(base, "/")+"/.well-known/openid-configuration", &doc); err != nil { + return nil, err + } + if err := validateDiscoveryIssuer(doc, issuerURL); err != nil { + return nil, err + } + if !strings.HasPrefix(doc.JWKSUri, "https://") { + return nil, fmt.Errorf("discovery document for %q has non-https jwks_uri %q", issuerURL, doc.JWKSUri) + } + + var keys jwkSet + if err := fetchJSON(ctx, client, doc.JWKSUri, &keys); err != nil { + return nil, err + } + if len(keys.Keys) == 0 { + return nil, fmt.Errorf("no keys published at %q", doc.JWKSUri) + } + if err := enforceJWKSKeyLimits(keys.Keys); err != nil { + return nil, fmt.Errorf("JWKS at %q: %w", doc.JWKSUri, err) + } + return &keys, nil +} + +// enforceJWKSKeyLimits rejects a key set exceeding AWS's documented OIDC +// provider limits (100 RSA and 100 EC keys) before it's cached or iterated +// over by keyFunc on every verification — an oversized or malicious JWKS +// response should fail fast rather than being accepted as a large key set to +// scan on every request. +func enforceJWKSKeyLimits(keys []jwk) error { + var rsaCount, ecCount int + for _, k := range keys { + switch k.Kty { + case "RSA": + rsaCount++ + case "EC": + ecCount++ + } + } + if rsaCount > maxJWKSKeysPerType { + return fmt.Errorf("%d RSA keys exceeds the %d-key limit", rsaCount, maxJWKSKeysPerType) + } + if ecCount > maxJWKSKeysPerType { + return fmt.Errorf("%d EC keys exceeds the %d-key limit", ecCount, maxJWKSKeysPerType) + } + return nil +} + +func fetchJSON(ctx context.Context, client *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d from %q", resp.StatusCode, url) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxOIDCFetchBodyBytes)) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// ssrfSafeHTTPClient returns an http.Client whose transport resolves each +// dial target's DNS once and rejects loopback/private/link-local/multicast +// addresses before connecting, mirroring FetchThumbprint's SSRF guard. It +// applies to every connection the client makes — including ones a redirect +// points at — since Transport.DialContext runs per underlying TCP +// connection, not just for the original request URL. CheckRedirect further +// refuses to follow any redirect whose target isn't https, since Go's +// default client would otherwise happily follow a discovery document (or +// its own redirect chain) down to plaintext http. +// +// TLS certificate verification is replaced with verifyOIDCConnection, which +// accepts a chain that matches one of thumbprints (AWS's documented +// trust-pinning fallback for self-signed/private-CA providers) even when +// standard CA-based verification would otherwise reject it, and falls back +// to ordinary hostname+CA verification against the system root pool +// whenever thumbprints is empty or doesn't match. +func ssrfSafeHTTPClient(thumbprints []string) *http.Client { + dialer := &net.Dialer{} + return &http.Client{ + Timeout: oidcFetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxOIDCFetchRedirects { + return fmt.Errorf("stopped after %d redirects", maxOIDCFetchRedirects) + } + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing to follow non-https redirect to %q", req.URL) + } + return nil + }, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, fmt.Errorf("dns lookup failed for %q", host) + } + for _, ip := range ips { + if isDisallowedFetchTarget(ip) { + return nil, fmt.Errorf("refusing to dial disallowed address %q for host %q", ip, host) + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // verified ourselves via VerifyConnection below + VerifyConnection: func(cs tls.ConnectionState) error { + return verifyOIDCConnection(cs, thumbprints) + }, + }, + }, + } +} + +// verifyOIDCConnection accepts cs's peer certificate chain if the top +// (topmost/intermediate CA) certificate's thumbprint matches any of +// thumbprints AND that certificate, used as the sole trust root, validates +// a signature path to the presented leaf for cs.ServerName — AWS's +// documented trust-pinning fallback trusts certificates *issued by* the +// pinned CA for the expected host, not merely any chain that happens to end +// in a certificate with that thumbprint. Thumbprint equality alone is never +// sufficient: an attacker can append the (non-secret) pinned certificate to +// an unrelated, unsigned chain, so the pinned certificate must also +// cryptographically issue the leaf and the leaf must match cs.ServerName. +// Falls back to standard hostname+CA verification against the system root +// pool whenever thumbprints is empty or none matches. +func verifyOIDCConnection(cs tls.ConnectionState, thumbprints []string) error { + if len(cs.PeerCertificates) == 0 { + return errors.New("iamutil: no certificate presented") + } + + if len(thumbprints) > 0 { + top := cs.PeerCertificates[len(cs.PeerCertificates)-1] + topThumbprint, err := ThumbprintFromChain(cs.PeerCertificates) + if err != nil { + return err + } + for _, pinned := range thumbprints { + if !strings.EqualFold(pinned, topThumbprint) { + continue + } + roots := x509.NewCertPool() + roots.AddCert(top) + opts := x509.VerifyOptions{ + DNSName: cs.ServerName, + Roots: roots, + Intermediates: x509.NewCertPool(), + } + if n := len(cs.PeerCertificates); n > 1 { + for _, cert := range cs.PeerCertificates[1 : n-1] { + opts.Intermediates.AddCert(cert) + } + } + if _, err := cs.PeerCertificates[0].Verify(opts); err == nil { + return nil + } + break + } + } + + opts := x509.VerifyOptions{ + DNSName: cs.ServerName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range cs.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := cs.PeerCertificates[0].Verify(opts) + return err +} diff --git a/iamapi/internal/iamutil/webidentity_test.go b/iamapi/internal/iamutil/webidentity_test.go new file mode 100644 index 00000000..49b53812 --- /dev/null +++ b/iamapi/internal/iamutil/webidentity_test.go @@ -0,0 +1,587 @@ +// 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 ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "math/big" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign test token: %v", err) + } + return signed +} + +func testJWKSet(t *testing.T, key *rsa.PrivateKey, kid string) *jwkSet { + t.Helper() + return &jwkSet{Keys: []jwk{{ + Kty: "RSA", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}} +} + +func TestParseWebIdentityClaims(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + valid := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com", "sub": "user1"}) + + tests := []struct { + name string + token string + wantErr bool + }{ + {name: "valid shape", token: valid}, + {name: "not a jwt", token: "not-a-jwt", wantErr: true}, + {name: "empty", token: "", wantErr: true}, + {name: "two segments", token: "aaaa.bbbb", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, err := ParseWebIdentityClaims(tt.token) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got claims %#v", claims) + } + var apiErr iamerr.Error + if !errors.As(err, &apiErr) || apiErr.Code != "InvalidIdentityToken" { + t.Fatalf("expected InvalidIdentityToken, got %#v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims["iss"] != "https://example.com" { + t.Fatalf("unexpected claims: %#v", claims) + } + }) + } +} + +func TestWebIdentityIssuer(t *testing.T) { + tests := []struct { + claims jwt.MapClaims + want string + wantOk bool + }{ + {claims: jwt.MapClaims{"iss": "https://example.com/path"}, want: "example.com/path", wantOk: true}, + // Not https: left unstripped so it can never coincidentally equal a + // registered (always-https) provider's stored Url. + {claims: jwt.MapClaims{"iss": "http://example.com"}, want: "http://example.com", wantOk: true}, + {claims: jwt.MapClaims{}, wantOk: false}, + {claims: jwt.MapClaims{"iss": ""}, wantOk: false}, + {claims: jwt.MapClaims{"iss": 123}, wantOk: false}, + } + for _, tt := range tests { + got, ok := WebIdentityIssuer(tt.claims) + if ok != tt.wantOk || (ok && got != tt.want) { + t.Errorf("WebIdentityIssuer(%#v) = (%q, %v), want (%q, %v)", tt.claims, got, ok, tt.want, tt.wantOk) + } + } +} + +func TestWebIdentityAudience(t *testing.T) { + tests := []struct { + name string + claims jwt.MapClaims + want string + wantOriginal []string + wantErr bool + }{ + {name: "single string aud", claims: jwt.MapClaims{"aud": "client1"}, want: "client1"}, + {name: "single-element array", claims: jwt.MapClaims{"aud": []any{"client1"}}, want: "client1"}, + {name: "no aud", claims: jwt.MapClaims{}, wantErr: true}, + {name: "empty aud", claims: jwt.MapClaims{"aud": ""}, wantErr: true}, + { + name: "multi aud with matching azp", + claims: jwt.MapClaims{"aud": []any{"other", "client1"}, "azp": "client1"}, + want: "client1", + wantOriginal: []string{"other", "client1"}, + }, + { + name: "multi aud without azp", + claims: jwt.MapClaims{"aud": []any{"other", "client1"}}, + wantErr: true, + }, + { + name: "single aud with azp: azp still wins, original aud exposed", + claims: jwt.MapClaims{ + "aud": "backend-project", "azp": "oauth-client-1", + }, + want: "oauth-client-1", + wantOriginal: []string{"backend-project"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, original, err := WebIdentityAudience(tt.claims) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + if !slices.Equal(original, tt.wantOriginal) { + t.Fatalf("original = %v, want %v", original, tt.wantOriginal) + } + }) + } +} + +func TestWebIdentityAudienceMultipleWithoutAzpMessage(t *testing.T) { + _, _, err := WebIdentityAudience(jwt.MapClaims{"aud": []any{"a", "b"}}) + var apiErr iamerr.Error + if !errors.As(err, &apiErr) { + t.Fatalf("expected iamerr.Error, got %#v", err) + } + if apiErr.Message != "Token audience contains more than one audience while authorized party is not present" { + t.Fatalf("unexpected message: %q", apiErr.Message) + } +} + +func TestVerifyWebIdentityExpiration(t *testing.T) { + now := time.Unix(1_000_000, 0) + + tests := []struct { + name string + exp float64 + wantErr bool + }{ + {name: "not yet expired", exp: float64(now.Unix() + 10)}, + {name: "within leeway", exp: float64(now.Unix() - 200)}, + {name: "expired beyond leeway", exp: float64(now.Unix() - 400), wantErr: true}, + {name: "missing exp", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + if tt.name != "missing exp" { + claims["exp"] = tt.exp + } + err := VerifyWebIdentityExpiration(claims, now) + if tt.wantErr != (err != nil) { + t.Fatalf("VerifyWebIdentityExpiration() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVerifyWebIdentityRequiredClaims(t *testing.T) { + now := time.Unix(1_000_000, 0) + + tests := []struct { + name string + claims jwt.MapClaims + wantErr bool + }{ + {name: "iat and sub present", claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1"}}, + {name: "missing iat", claims: jwt.MapClaims{"sub": "user1"}, wantErr: true}, + {name: "missing sub", claims: jwt.MapClaims{"iat": float64(now.Unix())}, wantErr: true}, + {name: "empty sub", claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": ""}, wantErr: true}, + { + name: "nbf in the past is fine", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() - 10)}, + }, + { + name: "nbf within leeway is fine", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() + 200)}, + }, + { + name: "nbf beyond leeway is not yet valid", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() + 400)}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := VerifyWebIdentityRequiredClaims(tt.claims, now) + if tt.wantErr != (err != nil) { + t.Fatalf("VerifyWebIdentityRequiredClaims() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVerifyOIDCConnection(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + conn, err := tls.Dial("tcp", srv.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + chain := conn.ConnectionState().PeerCertificates + + thumbprint, err := ThumbprintFromChain(chain) + if err != nil { + t.Fatalf("ThumbprintFromChain: %v", err) + } + + t.Run("matching pinned thumbprint bypasses CA trust but still requires a valid chain for the host", func(t *testing.T) { + // The httptest cert's SANs include "example.com" (see + // net/http/internal/testcert), and it is self-signed, so it forms a + // valid one-certificate chain rooted at itself for that name. + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err != nil { + t.Fatalf("expected pinned thumbprint to be accepted for a matching hostname: %v", err) + } + }) + + t.Run("matching pinned thumbprint does not bypass hostname verification", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "totally-different-host.example"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil { + t.Fatal("expected pinned thumbprint to still be rejected for a non-matching hostname") + } + }) + + t.Run("pinned thumbprint match does not bypass chain validation for an appended unrelated leaf", func(t *testing.T) { + // An attacker-controlled leaf (self-signed by a key the pinned CA + // never touched) followed by the real pinned certificate must not + // validate: thumbprint equality alone must not grant trust when the + // pinned certificate never actually issued this leaf. + unrelatedLeaf := generateSelfSignedCert(t, "example.com") + + forged := append([]*x509.Certificate{unrelatedLeaf}, chain...) + cs := tls.ConnectionState{PeerCertificates: forged, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil { + t.Fatal("expected forged chain (unrelated leaf + appended pinned cert) to be rejected") + } + }) + + t.Run("non-matching thumbprint falls back to standard verification and fails", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{"0000000000000000000000000000000000000000"}); err == nil { + t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool") + } + }) + + t.Run("no thumbprints falls back to standard verification and fails", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, nil); err == nil { + t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool") + } + }) + + t.Run("no certificates presented", func(t *testing.T) { + if err := verifyOIDCConnection(tls.ConnectionState{}, nil); err == nil { + t.Fatal("expected error when no certificate is presented") + } + }) +} + +func TestVerifySignatureWithKeys(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate other key: %v", err) + } + + keys := testJWKSet(t, key, "k1") + + t.Run("valid signature", func(t *testing.T) { + token := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com", "sub": "u1"}) + claims, err := verifySignatureWithKeys(token, keys) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims["sub"] != "u1" { + t.Fatalf("unexpected claims: %#v", claims) + } + }) + + t.Run("wrong signing key", func(t *testing.T) { + token := signTestToken(t, otherKey, "k1", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err == nil { + t.Fatal("expected signature verification failure") + } + }) + + t.Run("no kid in token, single key set still matches", func(t *testing.T) { + token := signTestToken(t, key, "", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err != nil { + t.Fatalf("single-key JWKS should match a token with no kid: %v", err) + } + }) + + t.Run("mismatched kid against single key set fails", func(t *testing.T) { + token := signTestToken(t, key, "unknown-kid", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err == nil { + t.Fatal("a kid that doesn't match the single known key should not be accepted") + } + }) + + t.Run("multi-key set reports errUnknownKID for an unrecognized kid", func(t *testing.T) { + multiKeySet := testJWKSet(t, key, "k1") + multiKeySet.Keys = append(multiKeySet.Keys, testJWKSet(t, otherKey, "k2").Keys[0]) + + token := signTestToken(t, key, "unknown-kid", jwt.MapClaims{"iss": "https://example.com"}) + _, err := verifySignatureWithKeys(token, multiKeySet) + if !errors.Is(err, errUnknownKID) { + t.Fatalf("expected errUnknownKID, got %v", err) + } + }) + + t.Run("tampered payload", func(t *testing.T) { + token := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com"}) + tampered := token[:len(token)-4] + "AAAA" + if _, err := verifySignatureWithKeys(tampered, keys); err == nil { + t.Fatal("expected tampered token to fail verification") + } + }) +} + +func TestRoleNameFromAssumeArn(t *testing.T) { + const account = "000000000000" + + tests := []struct { + name string + arn string + wantName string + wantFound bool + }{ + {name: "simple", arn: "arn:aws:iam::000000000000:role/my-role", wantName: "my-role", wantFound: true}, + {name: "with path", arn: "arn:aws:iam::000000000000:role/path/to/my-role", wantName: "my-role", wantFound: true}, + {name: "wrong account", arn: "arn:aws:iam::111111111111:role/my-role", wantFound: false}, + {name: "wrong resource type", arn: "arn:aws:iam::000000000000:user/my-user", wantFound: false}, + {name: "not an arn", arn: "not-an-arn", wantFound: false}, + {name: "empty resource", arn: "arn:aws:iam::000000000000:role/", wantFound: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := RoleNameFromAssumeArn(tt.arn, account) + if ok != tt.wantFound || (ok && got != tt.wantName) { + t.Errorf("RoleNameFromAssumeArn(%q) = (%q, %v), want (%q, %v)", tt.arn, got, ok, tt.wantName, tt.wantFound) + } + }) + } +} + +func TestValidateRoleSessionName(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "valid", value: "my-session_1.2@3"}, + {name: "too short", value: "a", wantErr: true}, + {name: "too long", value: string(make([]byte, 65)), wantErr: true}, + {name: "invalid chars", value: "bad session!!", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRoleSessionName(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateRoleSessionName(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestExtractClaimContext(t *testing.T) { + claims := jwt.MapClaims{ + "iss": "https://example.com", + "aud": "client1", + "sub": "user1", + "exp": float64(1000), + "amr": []any{"pwd", "mfa"}, + "groups": "admins", + // golang-jwt decodes every JSON number as float64 and every JSON + // bool as bool - without claimScalarString handling both, a Bool or + // Numeric trust-policy Condition against a custom claim like these + // would silently never match, since the claim would never reach + // the output map at all (the key would always look "absent"). + "tier": float64(3), + "admin": true, + "scores": []any{float64(1), "x", true}, + } + got := ExtractClaimContext(claims) + + if _, ok := got["iss"]; ok { + t.Errorf("well-known claim iss should be excluded, got %#v", got) + } + if got["groups"][0] != "admins" { + t.Errorf("unexpected groups value: %#v", got["groups"]) + } + if len(got["amr"]) != 2 || got["amr"][0] != "pwd" || got["amr"][1] != "mfa" { + t.Errorf("unexpected amr value: %#v", got["amr"]) + } + if len(got["tier"]) != 1 || got["tier"][0] != "3" { + t.Errorf("unexpected tier value: %#v", got["tier"]) + } + if len(got["admin"]) != 1 || got["admin"][0] != "true" { + t.Errorf("unexpected admin value: %#v", got["admin"]) + } + if len(got["scores"]) != 3 || got["scores"][0] != "1" || got["scores"][1] != "x" || got["scores"][2] != "true" { + t.Errorf("unexpected scores value: %#v", got["scores"]) + } +} + +func TestBuildAssumedRoleArn(t *testing.T) { + got := BuildAssumedRoleArn("000000000000", "my-role", "my-session") + want := "arn:aws:sts::000000000000:assumed-role/my-role/my-session" + if got != want { + t.Errorf("BuildAssumedRoleArn() = %q, want %q", got, want) + } +} + +// generateSelfSignedCert returns a freshly generated, self-signed +// certificate for dnsName, signed by a key unrelated to any other +// certificate in the test — used to simulate an attacker-controlled leaf +// that a real pinned CA never issued. +func generateSelfSignedCert(t *testing.T, dnsName string) *x509.Certificate { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{dnsName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return cert +} + +func TestValidateDiscoveryIssuer(t *testing.T) { + tests := []struct { + name string + doc oidcDiscoveryDoc + issuerURL string + wantErr bool + }{ + {name: "matching issuer", doc: oidcDiscoveryDoc{Issuer: "https://example.com"}, issuerURL: "example.com", wantErr: false}, + {name: "mismatched issuer", doc: oidcDiscoveryDoc{Issuer: "https://attacker.example"}, issuerURL: "example.com", wantErr: true}, + {name: "missing issuer", doc: oidcDiscoveryDoc{Issuer: ""}, issuerURL: "example.com", wantErr: true}, + {name: "issuer with different path is not an exact match", doc: oidcDiscoveryDoc{Issuer: "https://example.com/tenant"}, issuerURL: "example.com", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDiscoveryIssuer(tt.doc, tt.issuerURL) + if (err != nil) != tt.wantErr { + t.Fatalf("validateDiscoveryIssuer(%+v, %q) error = %v, wantErr %v", tt.doc, tt.issuerURL, err, tt.wantErr) + } + }) + } +} + +func TestJWKSCacheKeyBindsThumbprints(t *testing.T) { + base := jwksCacheKey("example.com", []string{"aaaa"}) + + if got := jwksCacheKey("example.com", []string{"bbbb"}); got == base { + t.Errorf("jwksCacheKey did not change when thumbprint changed: %q", got) + } + if got := jwksCacheKey("example.com", nil); got == base { + t.Errorf("jwksCacheKey did not change when thumbprint was removed: %q", got) + } + if got := jwksCacheKey("other.example.com", []string{"aaaa"}); got == base { + t.Errorf("jwksCacheKey did not change when issuer changed: %q", got) + } + // Storage doesn't guarantee ThumbprintList order is stable across reads + // of an unchanged provider, so the key must not depend on input order. + if got := jwksCacheKey("example.com", []string{"bbbb", "aaaa"}); got != jwksCacheKey("example.com", []string{"aaaa", "bbbb"}) { + t.Errorf("jwksCacheKey is sensitive to thumbprint order: %q", got) + } +} + +func TestForceRefreshJWKSCacheGatesFailedAttempts(t *testing.T) { + issuer := "localhost" + key := jwksCacheKey(issuer, nil) + jwksCacheMu.Lock() + delete(jwksCache, key) + jwksCacheMu.Unlock() + t.Cleanup(func() { + jwksCacheMu.Lock() + delete(jwksCache, key) + jwksCacheMu.Unlock() + }) + + ctx := context.Background() + + if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil { + t.Fatal("forceRefreshJWKSCache() = nil error, want an error for a disallowed loopback target") + } + + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + jwksCacheMu.Unlock() + if !ok || entry.lastForcedRefresh.IsZero() { + t.Fatal("forceRefreshJWKSCache did not record lastForcedRefresh for a failed attempt") + } + before := entry.lastForcedRefresh + + // A second forced refresh within jwksMinForcedRefreshInterval must be + // gated - failing immediately with no cached keys to fall back on - + // rather than attempting another fetch. + if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil { + t.Fatal("forceRefreshJWKSCache() = nil error on gated retry, want an error (no cached keys available)") + } + jwksCacheMu.Lock() + after := jwksCache[key].lastForcedRefresh + jwksCacheMu.Unlock() + if !after.Equal(before) { + t.Errorf("forceRefreshJWKSCache re-attempted a fetch within jwksMinForcedRefreshInterval: lastForcedRefresh changed from %v to %v", before, after) + } +} diff --git a/iamapi/policy/condition.go b/iamapi/policy/condition.go new file mode 100644 index 00000000..64fa9a86 --- /dev/null +++ b/iamapi/policy/condition.go @@ -0,0 +1,500 @@ +// 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 policy + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "regexp" + "strconv" + "strings" + "time" + + "github.com/versity/versitygw/debuglogger" +) + +// ConditionValues decodes the value(s) of a single Condition operator/key +// pair. Unlike Action/Resource's string-only StringOrSlice, a Condition +// value may also be a bare JSON number or boolean rather than +// being re-serialized, so e.g. "5.50" round-trips as "5.50", not "5.5". A +// JSON null value or a non-scalar (object/array) element is rejected. +type ConditionValues []string + +func (c *ConditionValues) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '[' { + var raws []json.RawMessage + if err := json.Unmarshal(trimmed, &raws); err != nil { + return err + } + values := make([]string, len(raws)) + for i, r := range raws { + s, ok := decodeConditionScalar(r) + if !ok { + return fmt.Errorf("policy: invalid condition value %s", r) + } + values[i] = s + } + *c = values + return nil + } + + s, ok := decodeConditionScalar(trimmed) + if !ok { + return fmt.Errorf("policy: invalid condition value %s", trimmed) + } + *c = ConditionValues{s} + return nil +} + +// decodeConditionScalar decodes a single JSON scalar (string, number, or +// bool) to its string form, rejecting null and any non-scalar (object, +// array) value. +func decodeConditionScalar(raw json.RawMessage) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return "", false + } + if trimmed[0] == '"' { + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return "", false + } + return s, true + } + switch string(trimmed) { + case "true", "false": + return string(trimmed), true + case "null": + return "", false + } + var num json.Number + if err := json.Unmarshal(trimmed, &num); err != nil { + return "", false + } + return num.String(), true +} + +// conditionQualifier is IAM's multivalued-context-key set operator, given as +// a "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name. +type conditionQualifier int + +const ( + qualifierNone conditionQualifier = iota + qualifierForAllValues + qualifierForAnyValue +) + +// conditionComparator is a single (policy value, request value) match test +// for one condition operator family, e.g. string equality or a numeric +// comparison. It never itself accounts for absence, IfExists, negation, or +// multivalued aggregation - those are handled by evaluateConditionKey and +// aggregate around it. +type conditionComparator func(expected, actual string) bool + +// conditionOperatorDef is a recognized condition operator's evaluation +// behavior: negate distinguishes a Not-family operator (StringNotEquals, +// ArnNotEquals, ...) from its positive counterpart - both share the same +// comparator, since "not equal" is just the equality test used differently +// (see aggregate), not a different comparison. +type conditionOperatorDef struct { + compare conditionComparator + negate bool +} + +// conditionRegistry is every condition operator base name this package +// recognizes, except "Null" (handled separately by evaluateNull - it has no +// value comparator at all, only a presence check). Populated below from +// AWS's documented condition operator reference. +var conditionRegistry = map[string]conditionOperatorDef{ + "StringEquals": {compare: stringExact}, + "StringNotEquals": {compare: stringExact, negate: true}, + "StringEqualsIgnoreCase": {compare: stringFold}, + "StringNotEqualsIgnoreCase": {compare: stringFold, negate: true}, + "StringLike": {compare: stringLike}, + "StringNotLike": {compare: stringLike, negate: true}, + + "NumericEquals": {compare: numericCompare(func(a, e float64) bool { return a == e })}, + "NumericNotEquals": {compare: numericCompare(func(a, e float64) bool { return a == e }), negate: true}, + "NumericLessThan": {compare: numericCompare(func(a, e float64) bool { return a < e })}, + "NumericLessThanEquals": {compare: numericCompare(func(a, e float64) bool { return a <= e })}, + "NumericGreaterThan": {compare: numericCompare(func(a, e float64) bool { return a > e })}, + "NumericGreaterThanEquals": {compare: numericCompare(func(a, e float64) bool { return a >= e })}, + + "DateEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) })}, + "DateNotEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) }), negate: true}, + "DateLessThan": {compare: dateCompare(func(a, e time.Time) bool { return a.Before(e) })}, + "DateLessThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.After(e) })}, + "DateGreaterThan": {compare: dateCompare(func(a, e time.Time) bool { return a.After(e) })}, + "DateGreaterThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.Before(e) })}, + + "Bool": {compare: boolMatch}, + + "BinaryEquals": {compare: binaryMatch}, + + // ArnEquals and ArnLike behave identically in real AWS (both wildcard + // -aware), and are matched here with the same whole-string globMatch + // already used for Action/Resource - do not "fix" ArnEquals to a strict + // == later, that would diverge from AWS behavior. + "ArnEquals": {compare: stringLike}, + "ArnLike": {compare: stringLike}, + "ArnNotEquals": {compare: stringLike, negate: true}, + "ArnNotLike": {compare: stringLike, negate: true}, + + "IpAddress": {compare: ipMatch}, + "NotIpAddress": {compare: ipMatch, negate: true}, +} + +func stringExact(expected, actual string) bool { return expected == actual } +func stringFold(expected, actual string) bool { return strings.EqualFold(expected, actual) } +func stringLike(expected, actual string) bool { return globMatch(expected, actual) } + +// numericCompare builds a comparator from a (actual, expected float64) -> +// bool test, matching AWS's direction convention (the request's value is +// compared against the policy's value). Either operand failing to parse as +// a number fails the comparison rather than erroring +func numericCompare(op func(actual, expected float64) bool) conditionComparator { + return func(expected, actual string) bool { + e, eerr := strconv.ParseFloat(expected, 64) + a, aerr := strconv.ParseFloat(actual, 64) + return eerr == nil && aerr == nil && op(a, e) + } +} + +// dateCompare builds a comparator from a (actual, expected time.Time) -> +// bool test, same direction convention as numericCompare. +func dateCompare(op func(actual, expected time.Time) bool) conditionComparator { + return func(expected, actual string) bool { + e, eok := parseConditionDate(expected) + a, aok := parseConditionDate(actual) + return eok && aok && op(a, e) + } +} + +// parseConditionDate parses a Date condition operand in either form AWS +// accepts: an RFC 3339 date-time, or Unix epoch seconds (optionally +// fractional). +func parseConditionDate(s string) (time.Time, bool) { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, true + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t, true + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + sec := int64(f) + nsec := int64((f - float64(sec)) * 1e9) + return time.Unix(sec, nsec).UTC(), true + } + return time.Time{}, false +} + +func boolMatch(expected, actual string) bool { + e, eerr := strconv.ParseBool(expected) + a, aerr := strconv.ParseBool(actual) + return eerr == nil && aerr == nil && e == a +} + +func binaryMatch(expected, actual string) bool { + e, eerr := base64.StdEncoding.DecodeString(expected) + a, aerr := base64.StdEncoding.DecodeString(actual) + return eerr == nil && aerr == nil && bytes.Equal(e, a) +} + +// ipMatch reports whether actual (an address) falls within cidr (a CIDR +// range, or an exact address treated as a /32 or /128), matching IAM's +// IpAddress/NotIpAddress condition operators. An unparseable operand on +// either side never matches (fails closed) rather than erroring. +func ipMatch(cidr, actual string) bool { + c := cidr + if !strings.Contains(c, "/") { + if ip := net.ParseIP(c); ip != nil && ip.To4() != nil { + c += "/32" + } else { + c += "/128" + } + } + _, network, err := net.ParseCIDR(c) + if err != nil { + return false + } + ip := net.ParseIP(actual) + return ip != nil && network.Contains(ip) +} + +// parsedOperator is a condition operator name decomposed into its set +// qualifier, base operator, and IfExists flag. +type parsedOperator struct { + qualifier conditionQualifier + base string + ifExists bool +} + +// parseOperatorName decomposes name (e.g. "ForAllValues:StringNotEqualsIfExists") +// into a parsedOperator, reporting ok=false if the base operator (after +// stripping a recognized qualifier prefix and IfExists suffix) isn't one +// conditionRegistry recognizes, or is "Null" (Null has no IfExists variant - +// "NullIfExists" is rejected here since after suffix-stripping "Null" isn't +// itself in conditionRegistry). A bare "Null", optionally qualifier-prefixed, is accepted +func parseOperatorName(name string) (parsedOperator, bool) { + op := name + qualifier := qualifierNone + switch { + case strings.HasPrefix(op, "ForAllValues:"): + qualifier = qualifierForAllValues + op = strings.TrimPrefix(op, "ForAllValues:") + case strings.HasPrefix(op, "ForAnyValue:"): + qualifier = qualifierForAnyValue + op = strings.TrimPrefix(op, "ForAnyValue:") + } + + if op == "Null" { + return parsedOperator{qualifier: qualifier, base: "Null"}, true + } + + base := strings.TrimSuffix(op, "IfExists") + ifExists := base != op + if _, ok := conditionRegistry[base]; !ok { + return parsedOperator{}, false + } + return parsedOperator{qualifier: qualifier, base: base, ifExists: ifExists}, true +} + +// conditionShapeValid checks raw (a statement's Condition block) against +// IAM's condition grammar for write-time validation: an object of operator +// -> (key -> value), where every operator name is recognized by +// parseOperatorName. An absent, null, or empty Condition is valid (matches +// evaluateCondition's "always matches" contract). +func conditionShapeValid(raw json.RawMessage) bool { + if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return true + } + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + return false + } + for operator := range block { + if _, ok := parseOperatorName(operator); !ok { + return false + } + } + return true +} + +// conditionVariableOperators is the subset of conditionRegistry that AWS +// documents as supporting ${...} policy-variable substitution in a +// Condition value: the String family and the Arn family (both ultimately +// whole-string comparisons). AWS's policy-variable documentation +// specifically excludes Numeric, Date, Boolean, Binary, IP address, and +// Null operators - a variable placed there is never substituted, regardless +// of document version. +var conditionVariableOperators = map[string]bool{ + "StringEquals": true, + "StringNotEquals": true, + "StringEqualsIgnoreCase": true, + "StringNotEqualsIgnoreCase": true, + "StringLike": true, + "StringNotLike": true, + "ArnEquals": true, + "ArnLike": true, + "ArnNotEquals": true, + "ArnNotLike": true, +} + +// evaluateCondition evaluates a policy statement's Condition block against +// ctxVars - a ":" keyed context for trust-policy +// evaluation, or an "aws:" keyed context for identity-policy +// evaluation. An absent or empty Condition always matches. version is the +// enclosing document's Version element: a ${...} policy variable in a +// Condition value is only ever substituted when version is exactly +// Version2012 AND the operator is one of conditionVariableOperators - +// AWS requires the 2012-10-17 policy version to use variables at all, and +// never expands them for Numeric/Date/Bool/Binary/IP/Null operators even +// then. A variable that doesn't qualify is left as literal text, the +// same fallback used for an absent/multivalued context key - so it simply +// won't match a real condition value, rather than silently expanding into +// something AWS itself wouldn't. +// +// matched reports whether the condition holds; ok reports whether it could +// be evaluated at all. ok is false only for a Condition block whose JSON +// shape or operator name conditionShapeValid would already reject - i.e. +// only for a document stored before that write-time validation existed, or +// containing a future operator this package doesn't yet recognize. Callers +// MUST treat ok=false as "cannot rule out a hidden Deny" and deny the whole +// evaluation, never as a non-match - see EvaluateIdentityPolicies and +// EvaluateWebIdentityTrust. +func evaluateCondition(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) { + if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return true, true + } + + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + debuglogger.Logf("policy condition block failed to parse: %v", err) + return false, false + } + + for operator, kvs := range block { + op, recognized := parseOperatorName(operator) + if !recognized { + debuglogger.Logf("policy condition: unrecognized operator %q", operator) + return false, false + } + for key, expected := range kvs { + actual, present := lookupContextValues(ctxVars, key) + if version == Version2012 && conditionVariableOperators[op.base] { + expected = substituteConditionValues(expected, ctxVars) + } + if !evaluateConditionKey(op, expected, actual, present) { + return false, true + } + } + } + return true, true +} + +// lookupContextValues retrieves ctxVars[key], matching key +// case-insensitively: AWS documents condition (and policy-variable) key +// *names* as case-insensitive - "aws:SourceIp" and "AWS:SOURCEIP" name the +// same key - even though the values held under that key remain +// case-sensitive. An exact match is tried first so the common case doesn't +// pay for a map scan. +func lookupContextValues(ctxVars map[string][]string, key string) ([]string, bool) { + if v, ok := ctxVars[key]; ok { + return v, true + } + for k, v := range ctxVars { + if strings.EqualFold(k, key) { + return v, true + } + } + return nil, false +} + +// policyVariablePattern matches a single "${...}" policy-variable +// placeholder, e.g. "${aws:username}". +var policyVariablePattern = regexp.MustCompile(`\$\{([A-Za-z0-9_:.\-]+)\}`) + +// substitutePolicyVariables replaces every ${key} placeholder in s with the +// single value ctxVars holds for key, looked up the same case-insensitive +// way as a Condition key. AWS only allows a single-valued context key to be +// used as a policy variable; a placeholder naming an absent or multivalued +// key is left as literal text, same as any other substring - so it simply +// won't match a real resource ARN or condition value, rather than being +// silently dropped and turning a Deny that relies on it into a no-op. +func substitutePolicyVariables(s string, ctxVars map[string][]string) string { + if !strings.Contains(s, "${") { + return s + } + return policyVariablePattern.ReplaceAllStringFunc(s, func(match string) string { + key := match[2 : len(match)-1] + values, ok := lookupContextValues(ctxVars, key) + if !ok || len(values) != 1 { + return match + } + return values[0] + }) +} + +// substituteConditionValues applies substitutePolicyVariables to every +// element of values, so e.g. a Condition of +// {"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}} compares +// against the requester's own username rather than the literal text. +func substituteConditionValues(values ConditionValues, ctxVars map[string][]string) ConditionValues { + out := make(ConditionValues, len(values)) + for i, v := range values { + out[i] = substitutePolicyVariables(v, ctxVars) + } + return out +} + +// evaluateConditionKey evaluates one operator/key pair of an already +// -parsed Condition block against actual (ctxVars[key]) and present +// (whether key was in ctxVars at all). +func evaluateConditionKey(op parsedOperator, expected ConditionValues, actual []string, present bool) bool { + if op.base == "Null" { + return evaluateNull(expected, present) + } + entry := conditionRegistry[op.base] // guaranteed present - parseOperatorName already validated op.base + + if op.qualifier == qualifierForAllValues && !present { + return true + } + if entry.negate { + if !present { + return true + } + return aggregate(op.qualifier, true, expected, actual, entry.compare) + } + if !present { + return op.ifExists + } + return aggregate(op.qualifier, false, expected, actual, entry.compare) +} + +// evaluateNull implements the Null condition operator: true if expected +// (normally exactly one of "true"/"false", case-insensitive) says the key +// must be absent ("true") and it is, or must be present ("false") and it +// is. A value that's neither "true" nor "false" never satisfies the +// condition (fails closed) +func evaluateNull(expected ConditionValues, present bool) bool { + for _, e := range expected { + switch { + case strings.EqualFold(e, "true"): + if !present { + return true + } + case strings.EqualFold(e, "false"): + if present { + return true + } + } + } + return false +} + +// aggregate reports whether expected/actual satisfy a condition-key match +// under qualifier's multivalued-context-key semantics. negate selects the +// Not-operator family, sharing the same per-pair comparator as its positive +// counterpart (see conditionRegistry). +func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValues, actual []string, cmp conditionComparator) bool { + matchesAny := func(a string) bool { + for _, e := range expected { + if cmp(e, a) { + return true + } + } + return false + } + + useForAll := qualifier == qualifierForAllValues || (qualifier == qualifierNone && negate) + if useForAll { + for _, a := range actual { + if ok := matchesAny(a); ok == negate { + return false + } + } + return true // vacuously true over an empty/absent actual + } + for _, a := range actual { + if ok := matchesAny(a); ok != negate { + return true + } + } + return false // vacuously false over an empty/absent actual +} diff --git a/iamapi/policy/condition_test.go b/iamapi/policy/condition_test.go new file mode 100644 index 00000000..121d4839 --- /dev/null +++ b/iamapi/policy/condition_test.go @@ -0,0 +1,761 @@ +// 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 policy + +import ( + "reflect" + "testing" +) + +// evalCondTest is the shared table shape for every TestEvaluateCondition* +// function below. wantErr means "evaluateCondition's ok return should be +// false" (the block's shape or an operator name couldn't be recognized) - +// distinct from want=false, which means the condition was evaluated fine +// but didn't match. +type evalCondTest struct { + name string + raw string + ctxVars map[string][]string + // version is the enclosing document's Version element: a Condition + // value's ${...} policy variable is only ever substituted + // when this is exactly Version2012. Left "" (no Version) for every + // existing case except the ones specifically testing substitution. + version string + want bool + wantErr bool +} + +func runEvalCondTests(t *testing.T, tests []evalCondTest) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, ok := evaluateCondition([]byte(tt.raw), tt.ctxVars, tt.version) + wantOk := !tt.wantErr + if ok != wantOk { + t.Fatalf("evaluateCondition() ok = %v, want %v", ok, wantOk) + } + if ok && matched != tt.want { + t.Errorf("evaluateCondition() matched = %v, want %v", matched, tt.want) + } + }) + } +} + +func TestEvaluateCondition(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + {name: "empty condition always matches", raw: ``, want: true}, + { + name: "StringEquals matches", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: true, + }, + { + name: "StringEquals mismatch", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: false, + }, + { + name: "StringEquals missing key fails closed", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: "StringEquals against multivalued context matches any", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other", "client1"}}, + want: true, + }, + { + name: "StringEquals against multivalued condition matches any", + raw: `{"StringEquals":{"example.com:aud":["client1","client2"]}}`, + ctxVars: map[string][]string{"example.com:aud": {"client2"}}, + want: true, + }, + { + name: "StringNotEquals matches when different", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: true, + }, + { + name: "StringNotEquals fails when equal", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: false, + }, + { + name: "StringNotEquals matches when key absent", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringNotEqualsIfExists is accepted and behaves like StringNotEquals", + raw: `{"StringNotEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: false, + }, + { + name: "StringLike wildcard matches", + raw: `{"StringLike":{"example.com:sub":"user-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: true, + }, + { + name: "StringLike wildcard mismatch", + raw: `{"StringLike":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: false, + }, + { + name: "StringLikeIfExists enforces match when key present", + raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: false, + }, + { + name: "StringLikeIfExists passes when key absent", + raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringNotLike matches when pattern doesn't match", + raw: `{"StringNotLike":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: true, + }, + { + name: "StringEqualsIgnoreCase matches regardless of case", + raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"alice"}}, + want: true, + }, + { + name: "StringEqualsIgnoreCase mismatch", + raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"bob"}}, + want: false, + }, + { + name: "StringNotEqualsIgnoreCase matches when different regardless of case", + raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"bob"}}, + want: true, + }, + { + name: "StringNotEqualsIgnoreCase fails when equal regardless of case", + raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"alice"}}, + want: false, + }, + { + name: "StringEqualsIfExists passes when key absent", + raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringEqualsIfExists enforces match when key present", + raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: false, + }, + { + name: "multiple operators must all pass", + raw: `{"StringEquals":{"example.com:aud":"client1"},"StringLike":{"example.com:sub":"user-*"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}, "example.com:sub": {"user-1"}}, + want: true, + }, + { + name: "unrecognized operator fails closed", + raw: `{"FooBarOperator":{"example.com:level":"1"}}`, + ctxVars: map[string][]string{"example.com:level": {"1"}}, + wantErr: true, + }, + { + name: "malformed condition JSON fails closed", + raw: `not json`, + wantErr: true, + }, + { + name: "malformed condition block shape (operator value not an object) fails closed", + raw: `{"StringEquals":"not an object"}`, + wantErr: true, + }, + { + name: "malformed condition block shape (operator value is an array) fails closed", + raw: `{"StringEquals":["not","a","map"]}`, + wantErr: true, + }, + { + // Condition key *names* are case-insensitive in AWS, even + // though the values they hold remain case-sensitive. + name: "condition key name matches case-insensitively", + raw: `{"StringEquals":{"AWS:UserName":"alice"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: true, + }, + { + name: "condition key name case-insensitive match still compares values case-sensitively", + raw: `{"StringEquals":{"AWS:UserName":"Alice"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + // A policy variable in a Condition value is substituted from + // the request context before comparing, the same as a + // Resource pattern. + name: "policy variable in condition value is substituted under version 2012-10-17", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}}, + version: Version2012, + want: true, + }, + { + name: "policy variable naming an absent key is left literal and so fails to match", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:nonexistent}"}}`, + ctxVars: map[string][]string{"iam:ResourceTag/owner": {"alice"}}, + version: Version2012, + want: false, + }, + { + // Without an explicit 2012-10-17 Version, AWS does not expand + // policy variables at all - the "${aws:username}" text is + // compared literally and so never matches a real tag value. + name: "policy variable is not substituted without version 2012-10-17", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}}, + want: false, + }, + { + // AWS never expands policy variables inside Numeric/Date/ + // Bool/Binary/IP/Null operators, even under version 2012-10-17 - + // a NumericEquals comparing aws:EpochTime against a literal + // "${aws:EpochTime}" never self-matches. + name: "policy variable is not substituted inside NumericEquals even under version 2012-10-17", + raw: `{"NumericEquals":{"aws:EpochTime":"${aws:EpochTime}"}}`, + ctxVars: map[string][]string{"aws:EpochTime": {"1700000000"}}, + version: Version2012, + want: false, + }, + }) +} + +func TestEvaluateConditionNumeric(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "NumericEquals matches", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericEquals mismatch", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"6"}}, + want: false, + }, + { + name: "NumericEquals accepts a bare JSON number condition value", + raw: `{"NumericEquals":{"s3:max-keys":5}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericEquals unparseable actual operand fails closed, not an error", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"not-a-number"}}, + want: false, + }, + { + name: "NumericNotEquals matches when different", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"6"}}, + want: true, + }, + { + name: "NumericNotEquals fails when equal", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericNotEquals matches when key absent", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "NumericLessThan matches", + raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"3"}}, + want: true, + }, + { + name: "NumericLessThan boundary does not match", + raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericLessThanEquals boundary matches", + raw: `{"NumericLessThanEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericGreaterThan matches", + raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"7"}}, + want: true, + }, + { + name: "NumericGreaterThan boundary does not match", + raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericGreaterThanEquals boundary matches", + raw: `{"NumericGreaterThanEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericGreaterThanEqualsIfExists passes when key absent", + raw: `{"NumericGreaterThanEqualsIfExists":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionDate(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "DateEquals matches same instant in RFC3339", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateEquals matches across RFC3339 vs epoch-seconds formats", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"1704067200"}}, + want: true, + }, + { + name: "DateEquals mismatch", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: false, + }, + { + name: "DateNotEquals matches when different", + raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateNotEquals matches when key absent", + raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "DateLessThan matches", + raw: `{"DateLessThan":{"aws:CurrentTime":"2024-06-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateGreaterThan matches", + raw: `{"DateGreaterThan":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateGreaterThanEquals boundary matches", + raw: `{"DateGreaterThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateLessThanEquals boundary matches", + raw: `{"DateLessThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "Date operator unparseable operand fails closed, not an error", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"not-a-date"}}, + want: false, + }, + }) +} + +func TestEvaluateConditionBool(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "Bool matches", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"true"}}, + want: true, + }, + { + name: "Bool mismatch", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"false"}}, + want: false, + }, + { + name: "Bool absent key fails closed", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: "BoolIfExists passes when key absent", + raw: `{"BoolIfExists":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "Bool garbage value fails closed, not an error", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"yes"}}, + want: false, + }, + { + name: "Bool accepts a bare JSON boolean condition value", + raw: `{"Bool":{"example.com:admin":true}}`, + ctxVars: map[string][]string{"example.com:admin": {"true"}}, + want: true, + }, + }) +} + +func TestEvaluateConditionBinary(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "BinaryEquals matches", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"aGVsbG8="}}, + want: true, + }, + { + name: "BinaryEquals mismatch", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"d29ybGQ="}}, + want: false, + }, + { + name: "BinaryEquals invalid base64 fails closed, not an error", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"not-valid-base64!!"}}, + want: false, + }, + }) +} + +func TestEvaluateConditionArn(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "ArnLike wildcard matches", + raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: true, + }, + { + name: "ArnLike cross-account mismatch", + raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}}, + want: false, + }, + { + name: "ArnEquals behaves identically to ArnLike (wildcard-aware)", + raw: `{"ArnEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: true, + }, + { + name: "ArnNotLike matches a non-matching ARN", + raw: `{"ArnNotLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}}, + want: true, + }, + { + name: "ArnNotEquals fails when the ARN matches", + raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: false, + }, + { + name: "ArnNotEquals matches when key absent", + raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionIP(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "IpAddress CIDR matches", + raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}, + want: true, + }, + { + name: "IpAddress CIDR mismatch", + raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: false, + }, + { + name: "IpAddress exact address treated as /32", + raw: `{"IpAddress":{"aws:SourceIp":"203.0.113.5"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: true, + }, + { + name: "NotIpAddress matches an address outside the range", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: true, + }, + { + name: "NotIpAddress fails for an address inside the range", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}, + want: false, + }, + { + name: "NotIpAddress matches when key absent", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionNull(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: `Null "true" matches when key absent`, + raw: `{"Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: `Null "true" fails when key present`, + raw: `{"Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + name: `Null "false" fails when key absent`, + raw: `{"Null":{"aws:username":"false"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: `Null "false" matches when key present`, + raw: `{"Null":{"aws:username":"false"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: true, + }, + { + name: "Null garbage value never satisfies", + raw: `{"Null":{"aws:username":"maybe"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + name: "ForAllValues:Null is accepted and behaves like plain Null", + raw: `{"ForAllValues:Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "NullIfExists is rejected - Null has no IfExists variant", + raw: `{"NullIfExists":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + wantErr: true, + }, + }) +} +func TestEvaluateConditionQualifiers(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "unqualified StringNotEquals denies when any actual value matches (pre-existing behavior, unchanged)", + raw: `{"StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: false, + }, + { + name: "ForAllValues:StringNotEquals denies when any actual value matches", + raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: false, + }, + { + name: "ForAnyValue:StringNotEquals allows when at least one actual value doesn't match", + raw: `{"ForAnyValue:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + { + name: "ForAllValues:StringEquals matches when every actual value is in the set", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + { + name: "ForAllValues:StringEquals fails when one actual value is outside the set", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "manager"}}, + want: false, + }, + { + name: "ForAllValues:StringEquals vacuously matches when the key is entirely absent", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "ForAllValues:StringNotEquals vacuously matches when the key is entirely absent", + raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "ForAnyValue:StringEquals matches when at least one actual value is in the set", + raw: `{"ForAnyValue:StringEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + }) +} + +func TestConditionValuesUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want ConditionValues + wantErr bool + }{ + {"string", `"alice"`, ConditionValues{"alice"}, false}, + {"integer number, unquoted", `5`, ConditionValues{"5"}, false}, + {"decimal number preserves literal text", `5.50`, ConditionValues{"5.50"}, false}, + {"bool true", `true`, ConditionValues{"true"}, false}, + {"bool false", `false`, ConditionValues{"false"}, false}, + {"array of strings", `["a","b"]`, ConditionValues{"a", "b"}, false}, + {"array mixing string/number/bool", `["a",5,true]`, ConditionValues{"a", "5", "true"}, false}, + {"null is rejected", `null`, nil, true}, + {"null array element is rejected", `["a",null]`, nil, true}, + {"nested array element is rejected", `[["a"]]`, nil, true}, + {"object element is rejected", `{"a":"b"}`, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got ConditionValues + err := got.UnmarshalJSON([]byte(tt.json)) + if tt.wantErr { + if err == nil { + t.Fatalf("UnmarshalJSON() error = nil, want non-nil") + } + return + } + if err != nil { + t.Fatalf("UnmarshalJSON() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("UnmarshalJSON() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestParseOperatorName(t *testing.T) { + tests := []struct { + name string + op string + wantOk bool + wantBase string + wantIfExists bool + wantQualif conditionQualifier + }{ + {name: "StringEquals", op: "StringEquals", wantOk: true, wantBase: "StringEquals"}, + {name: "StringEqualsIfExists", op: "StringEqualsIfExists", wantOk: true, wantBase: "StringEquals", wantIfExists: true}, + {name: "NumericGreaterThanEquals", op: "NumericGreaterThanEquals", wantOk: true, wantBase: "NumericGreaterThanEquals"}, + {name: "DateLessThanIfExists", op: "DateLessThanIfExists", wantOk: true, wantBase: "DateLessThan", wantIfExists: true}, + {name: "Bool", op: "Bool", wantOk: true, wantBase: "Bool"}, + {name: "BoolIfExists", op: "BoolIfExists", wantOk: true, wantBase: "Bool", wantIfExists: true}, + {name: "BinaryEquals", op: "BinaryEquals", wantOk: true, wantBase: "BinaryEquals"}, + {name: "ArnLike", op: "ArnLike", wantOk: true, wantBase: "ArnLike"}, + {name: "IpAddress", op: "IpAddress", wantOk: true, wantBase: "IpAddress"}, + {name: "Null", op: "Null", wantOk: true, wantBase: "Null"}, + {name: "ForAllValues:StringEquals", op: "ForAllValues:StringEquals", wantOk: true, wantBase: "StringEquals", wantQualif: qualifierForAllValues}, + {name: "ForAnyValue:StringNotEqualsIfExists", op: "ForAnyValue:StringNotEqualsIfExists", wantOk: true, wantBase: "StringNotEquals", wantIfExists: true, wantQualif: qualifierForAnyValue}, + {name: "ForAllValues:Null accepted, qualifier is a no-op", op: "ForAllValues:Null", wantOk: true, wantBase: "Null", wantQualif: qualifierForAllValues}, + {name: "NullIfExists rejected", op: "NullIfExists", wantOk: false}, + {name: "unrecognized base", op: "FooBarOperator", wantOk: false}, + {name: "unrecognized qualifier prefix left as part of the name", op: "ForSomeValues:StringEquals", wantOk: false}, + {name: "empty string", op: "", wantOk: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseOperatorName(tt.op) + if ok != tt.wantOk { + t.Fatalf("parseOperatorName(%q) ok = %v, want %v", tt.op, ok, tt.wantOk) + } + if !ok { + return + } + if got.base != tt.wantBase || got.ifExists != tt.wantIfExists || got.qualifier != tt.wantQualif { + t.Fatalf("parseOperatorName(%q) = %+v, want {base:%q ifExists:%v qualifier:%v}", tt.op, got, tt.wantBase, tt.wantIfExists, tt.wantQualif) + } + }) + } +} + +func TestGlobMatch(t *testing.T) { + tests := []struct { + pattern, s string + want bool + }{ + {pattern: "user-*", s: "user-123", want: true}, + {pattern: "user-*", s: "admin-123", want: false}, + {pattern: "user-?23", s: "user-123", want: true}, + {pattern: "user-?23", s: "user-1123", want: false}, + {pattern: "*", s: "anything", want: true}, + {pattern: "exact", s: "exact", want: true}, + {pattern: "exact", s: "exacts", want: false}, + } + for _, tt := range tests { + if got := globMatch(tt.pattern, tt.s); got != tt.want { + t.Errorf("globMatch(%q, %q) = %v, want %v", tt.pattern, tt.s, got, tt.want) + } + } +} diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go index 50490de2..31866eba 100644 --- a/iamapi/policy/document.go +++ b/iamapi/policy/document.go @@ -17,6 +17,7 @@ package policy import ( "bytes" "encoding/json" + "fmt" ) // Recognized values for a policy document's Version element. @@ -41,10 +42,7 @@ type Statement struct { NotResource StringOrSlice Principal json.RawMessage NotPrincipal json.RawMessage - // Condition is never structurally validated (neither the identity- nor - // trust-policy path models its grammar) — it is only checked for - // presence, by the trust-policy Cognito-provider rule. - Condition json.RawMessage + Condition json.RawMessage } // UnmarshalJSON accepts Statement as either a single JSON object or an @@ -53,6 +51,17 @@ type Statement struct { // here — Validate reports that as a grammar error so all "empty document" // shapes produce the same message. func (d *Document) UnmarshalJSON(data []byte) error { + // A duplicate key anywhere in the document (top-level Version/Statement, + // a statement's Effect/Action, a Principal key, a nested Condition + // operator or context key, ...) is ambiguous: Go's json package silently + // keeps the last occurrence, but real AWS's policy simulator rejects + // e.g. a duplicated "Effect":"Deny","Effect":"Allow" outright as + // InvalidInput rather than picking one. Reject the whole document + // up front, structurally, rather than special-casing every field. + if err := rejectDuplicateJSONKeys(data); err != nil { + return err + } + var raw struct { Version string Statement json.RawMessage @@ -67,19 +76,99 @@ func (d *Document) UnmarshalJSON(data []byte) error { } var stmts []Statement - if err := json.Unmarshal(raw.Statement, &stmts); err == nil { + if err := unmarshalStrict(raw.Statement, &stmts); err == nil { d.Statement = stmts return nil } var single Statement - if err := json.Unmarshal(raw.Statement, &single); err != nil { + if err := unmarshalStrict(raw.Statement, &single); err != nil { return err } d.Statement = []Statement{single} return nil } +// rejectDuplicateJSONKeys reports an error if any JSON object anywhere in +// raw — at any nesting depth: the top-level document, an individual +// statement, its Principal, or a Condition block's operator/key maps — +// contains the same key twice. The standard decoder accepts this silently +// and keeps the last occurrence, which can turn e.g. a written +// "Effect":"Deny","Effect":"Allow" (rejected by AWS's own policy simulator +// as InvalidInput) into a working Allow instead of a rejected document +func rejectDuplicateJSONKeys(raw []byte) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + tok, err := dec.Token() + if err != nil { + return err + } + return checkDuplicateJSONKeys(dec, tok) +} + +// checkDuplicateJSONKeys recursively walks the value tok (already read from +// dec) for duplicate object keys, consuming the rest of that value's tokens +// from dec — including its closing delimiter, for an object or array — before +// returning. +func checkDuplicateJSONKeys(dec *json.Decoder, tok json.Token) error { + delim, ok := tok.(json.Delim) + if !ok { + return nil // scalar (string/number/bool/null): nothing nested to check + } + + switch delim { + case '{': + seen := make(map[string]struct{}) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key := keyTok.(string) + if _, dup := seen[key]; dup { + return fmt.Errorf("policy: duplicate key %q", key) + } + seen[key] = struct{}{} + + valTok, err := dec.Token() + if err != nil { + return err + } + if err := checkDuplicateJSONKeys(dec, valTok); err != nil { + return err + } + } + _, err := dec.Token() // consume '}' + return err + case '[': + for dec.More() { + valTok, err := dec.Token() + if err != nil { + return err + } + if err := checkDuplicateJSONKeys(dec, valTok); err != nil { + return err + } + } + _, err := dec.Token() // consume ']' + return err + } + return nil +} + +// unmarshalStrict decodes data into v, rejecting any object field that +// doesn't correspond to one of v's exported struct fields - unlike plain +// json.Unmarshal, which silently ignores unrecognized fields. Used for +// Statement specifically, so e.g. a "Conditon" typo is rejected as a +// malformed policy document rather than silently producing an unconditional Allow/Deny +// Statement's field set (Sid/Effect/Action/NotAction/Resource/NotResource/ +// Principal/NotPrincipal/Condition) is AWS's complete statement grammar, so +// nothing legitimate is rejected by this. +func unmarshalStrict(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + // StringOrSlice decodes a JSON value that may be either a single string or // an array of strings, matching the AWS IAM policy grammar for Action, // NotAction, Resource, and NotResource. A JSON-null value decodes to a nil diff --git a/iamapi/policy/document_test.go b/iamapi/policy/document_test.go index bf9437b2..9aaa9d69 100644 --- a/iamapi/policy/document_test.go +++ b/iamapi/policy/document_test.go @@ -111,4 +111,47 @@ func TestDocumentUnmarshalJSON(t *testing.T) { t.Fatal("Unmarshal() error = nil, want non-nil") } }) + + t.Run("unknown field on a statement in an array is rejected", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Conditon":{"StringEquals":{"aws:username":"alice"}}}]}`), &doc) + if err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) + + t.Run("unknown field on a single-object statement is rejected", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Conditon":{"StringEquals":{"aws:username":"alice"}}}}`), &doc) + if err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) + + t.Run("every legitimate statement field at once still succeeds", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Sid":"S1","Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("unknown top-level document field is not rejected", func(t *testing.T) { + // Unlike Statement, Document's outer decode is deliberately not + // strict: real IAM documents can carry a top-level "Id" field this + // codebase doesn't model, and DisallowUnknownFields is recursive so + // it still catches a Statement-level typo without the outer struct + // needing it too. + var doc Document + err := json.Unmarshal([]byte(`{"Id":"some-policy-id","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) } diff --git a/iamapi/policy/identity.go b/iamapi/policy/identity.go new file mode 100644 index 00000000..4ee29dab --- /dev/null +++ b/iamapi/policy/identity.go @@ -0,0 +1,150 @@ +// 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 policy + +import ( + "encoding/json" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/types" +) + +// MaxSessionPolicyBytes is the maximum length, in bytes, of the optional +// inline session policy document AssumeRoleWithWebIdentity's Policy +// parameter accepts, matching AWS's documented quota for that parameter. +const MaxSessionPolicyBytes = 2048 + +// RequestContext carries the request-scoped values an identity-policy +// statement is evaluated against, matching AWS's treatment of authorization +// as a full request-context decision (action, resource, and condition — +// principal is already fixed by which documents are passed in) rather than +// the action name alone. +type RequestContext struct { + // Action is the ":" string being authorized, e.g. + // "iam:GetRole". + Action string + // Resource is the ARN of the specific resource the action targets + // (e.g. a role's own Arn for GetRole, or "*" for an action AWS + // classifies as resource-less, such as a List action). + Resource string + // Condition is the "aws:"-keyed context (aws:SourceIp, + // aws:username, aws:PrincipalArn, aws:userid, ...) a statement's + // Condition block is evaluated against. + Condition map[string][]string +} + +// EvaluateIdentityPolicies reports whether reqCtx is allowed by documents +// (each a user's or role's inline policy entry), using IAM's evaluation +// semantics: a statement must cover the action, the resource, and (if +// present) its Condition block to be considered at all; an explicit Deny +// statement that does so makes the whole evaluation deny regardless of any +// Allow found elsewhere (in the same or another document), and absent an +// explicit deny, at least one covering Allow statement is required — so an +// identity with no matching statement at all is denied by default. +// +// A document that fails to parse, or a statement whose Condition block can't +// be evaluated (see evaluateCondition's ok return), denies the whole +// evaluation rather than being skipped: PutUserPolicy/PutRolePolicy already +// reject any policy document that wouldn't parse or whose Condition uses an +// unrecognized operator, so this only matters for documents written before +// that validation existed - and for exactly that legacy-data case, we can't +// rule out a hidden Deny inside the part we can't evaluate, so the safe +// outcome is to deny rather than silently proceed as if it wasn't there. +func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) bool { + allowed := false + + for _, entry := range documents { + var doc Document + if err := json.Unmarshal([]byte(entry.PolicyDocument), &doc); err != nil { + debuglogger.Logf("identity policy document failed to parse: %v", err) + return false + } + // PutUserPolicy/PutRolePolicy already reject a document that + // wouldn't pass Validate (e.g. both Action and NotAction on one + // statement) at write time, but a document stored before that + // validation existed — or reaching storage through a migration, + // backup restore, or out-of-band write — could still fail it. Assign + // no meaning to a document AWS itself would reject rather than + // evaluating it anyway: re-check it here, at the security boundary, + // not just at ingress. + if err := doc.Validate(); err != nil { + debuglogger.Logf("identity policy document failed validation: %v", err) + return false + } + + for _, stmt := range doc.Statement { + if stmt.Effect != "Allow" && stmt.Effect != "Deny" { + continue + } + if !statementCoversAction(stmt, reqCtx.Action) { + continue + } + if !statementCoversResource(stmt, reqCtx.Resource, reqCtx.Condition, doc.Version) { + continue + } + matched, ok := evaluateCondition(stmt.Condition, reqCtx.Condition, doc.Version) + if !ok { + debuglogger.Logf("identity policy evaluation: statement condition could not be evaluated, denying") + return false + } + if !matched { + continue + } + + if stmt.Effect == "Deny" { + debuglogger.Logf("identity policy evaluation: action %q on resource %q explicitly denied", reqCtx.Action, reqCtx.Resource) + return false + } + allowed = true + } + } + + return allowed +} + +// statementCoversResource reports whether stmt's Resource/NotResource +// authorizes resource. Matching is case-sensitive (unlike action matching): +// ARNs are case-sensitive. version is the enclosing document's Version +// element: each pattern has policy variables (e.g. "${aws:username}") +// substituted from ctxVars before matching only when version is exactly +// Version2012 — AWS documents policy variables as requiring the +// 2012-10-17 policy version; a document with no Version, or the older +// 2008-10-17, matches Resource patterns containing "${...}" as the literal +// text instead, the same as real AWS. A statement with neither Resource nor +// NotResource never matches — Validate already requires every statement to +// carry one, so this only matters for documents written before that +// validation existed. +func statementCoversResource(stmt Statement, resource string, ctxVars map[string][]string, version string) bool { + if len(stmt.Resource) > 0 { + return matchAnyResource(stmt.Resource, resource, ctxVars, version) + } + if len(stmt.NotResource) > 0 { + return !matchAnyResource(stmt.NotResource, resource, ctxVars, version) + } + return false +} + +func matchAnyResource(patterns []string, resource string, ctxVars map[string][]string, version string) bool { + for _, p := range patterns { + pattern := p + if version == Version2012 { + pattern = substitutePolicyVariables(p, ctxVars) + } + if globMatch(pattern, resource) { + return true + } + } + return false +} diff --git a/iamapi/policy/identity_test.go b/iamapi/policy/identity_test.go new file mode 100644 index 00000000..c6b1ff58 --- /dev/null +++ b/iamapi/policy/identity_test.go @@ -0,0 +1,268 @@ +// 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 policy + +import ( + "testing" + + "github.com/versity/versitygw/iamapi/types" +) + +func policyEntries(documents ...string) []types.PolicyEntry { + entries := make([]types.PolicyEntry, len(documents)) + for i, doc := range documents { + entries[i] = types.PolicyEntry{PolicyDocument: doc} + } + return entries +} + +func TestEvaluateIdentityPolicies(t *testing.T) { + tests := []struct { + name string + documents []types.PolicyEntry + reqCtx RequestContext + want bool + }{ + { + name: "no documents denies by default", + documents: nil, + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "no matching statement denies by default", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "matching allow statement allows", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + name: "wildcard action allows", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + name: "explicit deny overrides an allow in another document", + documents: policyEntries( + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`, + ), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "explicit deny overrides an allow in the same document", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "action match is case-insensitive", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"IAM:CREATEUSER","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + // A malformed document might have contained a Deny we can no + // longer see, so the whole evaluation denies rather than + // silently proceeding as if the document wasn't there. + name: "malformed document denies the whole evaluation, even with a valid Allow elsewhere", + documents: policyEntries(`not json`, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "malformed document denies the whole evaluation regardless of document order", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`, `not json`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + // A Deny guarded by a Condition operator this package doesn't + // recognize (simulating a legacy document stored before + // write-time validation existed - Parse() would reject this + // today) must not be silently skipped in favor of the Allow + // underneath it. + name: "unrecognized operator on a Deny denies, does not let an Allow underneath it win", + documents: policyEntries( + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, + ), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + // Fail-closed on a condition-evaluation error isn't scoped to + // Deny statements specifically - it's a deny-all result for the + // whole evaluation. + name: "unrecognized operator on an Allow-only statement still denies (fail-closed is not Deny-specific)", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + // A document containing any statement Validate() would + // reject (here, an unrelated statement's unrecognized condition + // operator) is invalid as a whole and denies every evaluation + // against it, even a request the offending statement doesn't + // itself cover - assigning no meaning to a document AWS itself + // would reject at write time is safer than evaluating the parts + // of it that happen to look fine. + name: "unrecognized operator in an unrelated statement invalidates the whole document", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:DeleteUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "Null operator end-to-end: denies presence of aws:username", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "Null operator end-to-end: allows when aws:username is absent (session, not user)", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:userid": {"role-id:session"}}}, + want: true, + }, + { + name: "NotAction denies coverage for the excluded action", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "NotAction allows actions outside the exclusion", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:DeleteUser", Resource: "*"}, + want: true, + }, + { + name: "resource-scoped allow matches the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: true, + }, + { + name: "resource-scoped allow does not cover a different resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: false, + }, + { + name: "resource match is case-sensitive", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/Role-A"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "resource-scoped deny only affects the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: true, + }, + { + name: "resource-scoped deny denies the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "NotResource excludes the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "NotResource allows resources outside the exclusion", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: true, + }, + { + // ${aws:username} in Resource must resolve to the requesting + // principal's own name before matching, not be compared as a + // literal string. + name: "policy variable in Resource matches the caller's own resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: true, + }, + { + name: "policy variable in Resource does not match a different principal's resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/bob", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "unresolvable policy variable in Resource is left literal and so does not match a real ARN", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice"}, + want: false, + }, + { + // AWS requires Version 2012-10-17 to use policy variables at + // all - the same statement under 2008-10-17 must treat + // "${aws:username}" as literal text, not expand it. + name: "policy variable in Resource is not substituted under version 2008-10-17", + documents: policyEntries(`{"Version":"2008-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "policy variable in Resource is not substituted with no Version at all", + documents: policyEntries(`{"Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "condition must match", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: true, + }, + { + name: "condition mismatch denies by default", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"bob"}}}, + want: false, + }, + { + name: "deny condition must also match to take effect", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}}, + want: true, + }, + { + name: "deny condition matching denies", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EvaluateIdentityPolicies(tt.documents, tt.reqCtx); got != tt.want { + t.Fatalf("EvaluateIdentityPolicies() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go index c86380f2..9c09a737 100644 --- a/iamapi/policy/trust.go +++ b/iamapi/policy/trust.go @@ -17,7 +17,6 @@ package policy import ( "encoding/json" "fmt" - "slices" "strings" "github.com/versity/versitygw/iamapi/iamerr" @@ -35,6 +34,66 @@ var trustPrincipalKeys = map[string]bool{ const cognitoFederatedProvider = "cognito-identity.amazonaws.com" +// azureSentinelProviderURL is Microsoft Sentinel's registered OIDC provider +// Url (scheme stripped) — a shared provider like the ones in +// sharedOIDCProviderRequiredClaim, but its required identity-provider +// control is not a claim on the token at all: AWS requires the trust +// statement's Condition to scope sts:RoleSessionName (a global STS +// condition key, see policy.go's requestConditionContext and +// webidentity.go's WebIdentityContext.RoleSessionName) instead of a +// ":" key, so it's handled as its own case in +// validateSharedProviderTenancy rather than fitting the shared map. +const azureSentinelProviderURL = "sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d" + +// azureSentinelRequiredKey is the condition key azureSentinelProviderURL's +// trust statements must scope. +const azureSentinelRequiredKey = "sts:RoleSessionName" + +// oidcProviderArnInfix is the fixed separator between the account segment +// and the provider Url in an OIDC provider ARN, matching +// iamutil.BuildOIDCProviderArn's "arn:aws:iam:::oidc-provider/" +// shape (this package can't import iamutil to reuse its ARN parser: iamutil +// already imports policy). +const oidcProviderArnInfix = ":oidc-provider/" + +// sharedOIDCProviderRequiredClaim maps a known shared-audience OIDC issuer's +// hostname (a registered provider's Url, scheme already stripped) to the +// claim suffix a trust statement federating it must scope with a Condition. +// AWS added this requirement for popular CI/CD OIDC issuers because their +// audience is commonly left at a single shared, non-secret default (e.g. +// "sts.amazonaws.com"): unlike a private or self-hosted provider, whose Url +// alone is already tenant-specific, the audience here doesn't distinguish +// one organization's/repo's token from any other's identically-configured +// one, so the trust policy must scope its tenancy claim itself. +// +// Sourced from AWS's own published table of shared OIDC providers and their +// required claims: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc_secure-by-default.html +// Amazon Cognito and Microsoft Sentinel are handled as +// their own special cases in validateSharedProviderTenancy rather than this +// map: Cognito's federated-principal value isn't an OIDC provider ARN at +// all, and Sentinel's required control is a global STS key, not a claim. +// IBM Turbonomic SaaS is a documented shared provider too, but AWS's own +// table declines to give it a fixed Url ("periodically updates their OIDC +// Issuer URL with new versions of the platform") — there is no stable +// hostname to key a map entry on, so it's deliberately omitted here. +var sharedOIDCProviderRequiredClaim = map[string]string{ + "token.actions.githubusercontent.com": "sub", // GitHub Actions + "vstoken.actions.githubusercontent.com": "sub", // GitHub vstoken + "oidc-configuration.audit-log.githubusercontent.com": "sub", // GitHub audit log streaming + "gitlab.com": "sub", // GitLab.com (SaaS) + "agent.buildkite.com": "sub", // Buildkite + "app.terraform.io": "sub", // HCP Terraform / Terraform Cloud + "oidc.codefresh.io": "sub", // Codefresh SaaS + "studio.datachain.ai/api": "sub", // DVC Studio + "scalr.io": "sub", // Scalr + "tokens.cloud.shisho.dev": "sub", // Shisho Cloud + "proidc.upbound.io": "sub", // Upbound + "api.pulumi.com/oidc": "aud", // Pulumi Cloud + "sandboxes.cloud": "aud", // sandboxes.cloud + "oidc.vercel.com": "aud", // Vercel global endpoint +} + // validServicePrincipals are the only Service principal values the gateway // recognizes. Real AWS validates Service against its live catalog of // ~300+ service principals; the gateway only exposes S3, STS, and IAM @@ -114,8 +173,11 @@ func (d Document) ValidateTrust() error { // ValidateTrust checks s against IAM trust-policy statement grammar: a // valid Effect, a required Principal (never NotPrincipal), an Action or -// NotAction with only "sts:"-prefixed values, and no Resource/NotResource. -// Condition is not modeled or validated(not supported at the moment) +// NotAction with only "sts:"-prefixed values, no Resource/NotResource, and - +// if present - a Condition block whose operators are all recognized (see +// conditionShapeValid, shared with the identity-policy side; condition +// *keys* and operand *values* are deliberately not validated here, matching +// AWS behavior). func (s Statement) ValidateTrust() error { switch s.Effect { case "Allow", "Deny": @@ -135,6 +197,19 @@ func (s Statement) ValidateTrust() error { return err } + if !conditionShapeValid(s.Condition) { + return errTrustSyntax + } + + if len(s.Action) > 0 && len(s.NotAction) > 0 { + // Same exclusivity identity policies already enforce (Statement.Validate): + // AWS documents Action and NotAction as mutually exclusive within a + // single statement, and real policy simulation rejects a document + // combining them with InvalidInput - a trust statement isn't + // exempt just because its evaluator (statementCoversAction) happens + // to have well-defined single-field behavior. + return errTrustSyntax + } if len(s.Action) == 0 && len(s.NotAction) == 0 { return errTrustMissingAction } @@ -187,7 +262,6 @@ func (s Statement) validateTrustPrincipal() error { return errTrustEmptyPrincipal } - requiresCondition := false for key, values := range principal { if !trustPrincipalKeys[key] { return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key)) @@ -199,14 +273,137 @@ func (s Statement) validateTrustPrincipal() error { } } } - if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) { - requiresCondition = true + } + + return validateSharedProviderTenancy(s, principal["Federated"]) +} + +// validateSharedProviderTenancy rejects a trust statement that federates a +// known shared-audience provider (Cognito Identity Pools, or a registered +// OIDC provider whose Url is in sharedOIDCProviderRequiredClaim) without a +// Condition that scopes the provider's tenant-identifying claim to a +// specific, non-wildcard value — see sharedOIDCProviderRequiredClaim's +// doc comment for why the audience alone isn't enough for these providers. +// A Federated value that doesn't match either shape (a private/self-hosted +// OIDC provider, or a value too malformed to resolve to a real provider at +// all) imposes no extra requirement here; those are unaffected by this +// check. +func validateSharedProviderTenancy(s Statement, federated []string) error { + for _, v := range federated { + if v == cognitoFederatedProvider { + if !conditionScopesClaim(s.Condition, cognitoFederatedProvider+":aud") { + return errTrustCognitoConditionRequired + } + continue + } + + url, ok := oidcProviderURLFromFederatedArn(v) + if !ok { + continue + } + + if url == azureSentinelProviderURL { + if !conditionScopesClaim(s.Condition, azureSentinelRequiredKey) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf( + "The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, azureSentinelRequiredKey)) + } + continue + } + + claim, known := sharedOIDCProviderRequiredClaim[url] + if !known { + continue + } + key := url + ":" + claim + if !conditionScopesClaim(s.Condition, key) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf( + "The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, key)) } } - - if requiresCondition && len(s.Condition) == 0 { - return errTrustCognitoConditionRequired - } - return nil } + +// oidcProviderURLFromFederatedArn extracts the provider Url from a Federated +// principal ARN shaped like "arn:aws:iam:::oidc-provider/" +// (see iamutil.BuildOIDCProviderArn), reporting ok=false for any value not +// shaped like an OIDC provider ARN at all — a bare federation identifier +// (e.g. "cognito-identity.amazonaws.com") or a malformed value, both handled +// elsewhere (this is deliberately a lightweight shape check, not full ARN +// validation: an actually-malformed ARN is caught later, when the runtime +// AssumeRoleWithWebIdentity path resolves it against real registered +// providers and finds nothing). +func oidcProviderURLFromFederatedArn(value string) (string, bool) { + _, url, ok := strings.Cut(value, oidcProviderArnInfix) + if !ok || url == "" { + return "", false + } + return url, true +} + +// conditionScopesClaim reports whether raw (a statement's Condition block) +// contains a positive String-family comparison (StringEquals, StringLike, or +// StringEqualsIgnoreCase — optionally ForAllValues/ForAnyValue-qualified; +// their Not-negated counterparts don't count, since excluding one value +// doesn't scope to a tenant) against key (matched case-insensitively, same +// as identity-policy condition keys) with at least one value that actually +// scopes the claim. For StringLike specifically — the one operator here +// where '*'/'?' are wildcards, not literal characters — a value consisting +// entirely of wildcard characters (e.g. "*", "**", "?", "*?*") is rejected +// even though it's non-empty: AWS documents that a shared provider's +// tenancy claim "must not consist only of wildcard characters", since +// a pattern with no literal character left after stripping '*'/'?' matches +// every possible value just as completely as a bare "*" does. StringEquals +// and StringEqualsIgnoreCase don't treat '*'/'?' as wildcards at all, so +// only the plain "empty or exactly '*'" check applies to them. A block that +// fails to parse reports false, same as an absent one — +// conditionShapeValid/evaluateCondition are responsible for rejecting or +// fail-closing a block this can't understand; this check only ever adds a +// stricter write-time requirement on top of that. +func conditionScopesClaim(raw json.RawMessage, key string) bool { + if len(raw) == 0 { + return false + } + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + return false + } + for operator, kvs := range block { + op, ok := parseOperatorName(operator) + if !ok { + continue + } + switch op.base { + case "StringEquals", "StringLike", "StringEqualsIgnoreCase": + default: + continue + } + for k, values := range kvs { + if !strings.EqualFold(k, key) { + continue + } + for _, v := range values { + if v == "" || v == "*" { + continue + } + if op.base == "StringLike" && !hasNonWildcardCharacter(v) { + continue + } + return true + } + } + } + return false +} + +// hasNonWildcardCharacter reports whether v contains at least one character +// other than the StringLike wildcards '*' (any run of characters) and '?' +// (any single character) — i.e. whether it scopes to anything narrower than +// "every possible value". +func hasNonWildcardCharacter(v string) bool { + for _, r := range v { + if r != '*' && r != '?' { + return true + } + } + return false +} diff --git a/iamapi/policy/trust_test.go b/iamapi/policy/trust_test.go index ecc51cf6..de888ec2 100644 --- a/iamapi/policy/trust_test.go +++ b/iamapi/policy/trust_test.go @@ -21,10 +21,8 @@ import ( "github.com/versity/versitygw/iamapi/iamerr" ) -// Every case below was verified against a live AWS IAM account, except -// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md). -// The "ec2 service (unsupported)" case is one such deliberate deviation: -// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, +// The "ec2 service (unsupported)" case is a deliberate deviation from real +// AWS: real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, // STS, and IAM APIs, so it restricts Service principals to those three. func TestParseTrust(t *testing.T) { tests := []struct { @@ -62,6 +60,7 @@ func TestParseTrust(t *testing.T) { {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden}, {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction}, + {"both action and notaction rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotAction":"sts:AssumeRoleWithWebIdentity"}]}`, errTrustSyntax}, {"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction}, {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction}, {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction}, @@ -73,6 +72,67 @@ func TestParseTrust(t *testing.T) { {"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired}, {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + // A condition block that doesn't actually scope the required aud + // claim must still be rejected, even though a condition is present. + {"cognito federated with unrelated condition (not aud) is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, errTrustCognitoConditionRequired}, + {"cognito federated with wildcard-only aud is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"*"}}}]}`, errTrustCognitoConditionRequired}, + + // Known shared-audience OIDC CI/CD providers (GitHub Actions, + // GitLab.com, Buildkite, Terraform Cloud) require a Condition scoping + // their "sub" claim, the same way Cognito requires "aud" — their + // audience is commonly left at a single non-secret shared default, so + // it alone doesn't distinguish one tenant's workflow from another's. + {"github actions federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with wildcard-only sub is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"*"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with scoped sub condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"repo:my-org/my-repo:*"}}}]}`, nil}, + {"gitlab federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/gitlab.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "gitlab.com" without a Condition scoping "gitlab.com:sub" to your own tenant.`)}, + + // Wildcard-only patterns must not satisfy a shared provider's + // required scoping - AWS documents that the tenancy claim "must not + // consist only of wildcard characters", not merely "must not be the + // bare string '*'". + {"github actions federated with double-wildcard sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"**"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with single-char-wildcard sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"?"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with mixed-wildcard-only sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"*?*"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + // A StringEquals value of literally "**" isn't a wildcard operator + // at all under that operator - it's compared as an exact literal + // string that will never match a real sub claim - so only the + // plain empty/"*" check applies to it, and "**" alone passes that. + {"github actions federated with StringEquals literal double-asterisk is accepted (not a wildcard operator)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"token.actions.githubusercontent.com:sub":"**"}}}]}`, nil}, + + // Additional shared providers from AWS's published table, beyond + // the original four. + {"pulumi federated without aud condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/api.pulumi.com/oidc"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "api.pulumi.com/oidc" without a Condition scoping "api.pulumi.com/oidc:aud" to your own tenant.`)}, + {"pulumi federated with scoped aud condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/api.pulumi.com/oidc"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"api.pulumi.com/oidc:aud":"my-org"}}}]}`, nil}, + {"vercel federated without aud condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/oidc.vercel.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "oidc.vercel.com" without a Condition scoping "oidc.vercel.com:aud" to your own tenant.`)}, + {"upbound federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/proidc.upbound.io"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "proidc.upbound.io" without a Condition scoping "proidc.upbound.io:sub" to your own tenant.`)}, + + // Microsoft Sentinel is a shared provider whose required control is + // the global sts:RoleSessionName key, not a claim on the token - a + // non-claim control distinct from every other entry here. + {"azure sentinel federated without RoleSessionName condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d" without a Condition scoping "sts:RoleSessionName" to your own tenant.`)}, + {"azure sentinel federated with scoped RoleSessionName condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"sts:RoleSessionName":"my-workspace"}}}]}`, nil}, + + // A private/self-hosted OIDC provider (not in the shared-provider + // table) imposes no extra Condition requirement - its Url is already + // tenant-specific, unlike the shared community providers above. + {"private oidc provider federated without condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/idp.my-company.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, nil}, + + {"valid condition, Null", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Null":{"aws:username":"true"}}}]}`, nil}, + {"valid condition, Bool", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, nil}, + {"valid condition, NumericEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"NumericEquals":{"example.com:level":"5"}}}]}`, nil}, + {"valid condition, ArnLike", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}}]}`, nil}, + {"valid condition, ForAllValues-qualified operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"ForAllValues:StringEquals":{"aws:TagKeys":["a","b"]}}}]}`, nil}, + + {"invalid condition, unrecognized operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, errTrustSyntax}, + {"invalid condition, malformed shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":"not an object"}]}`, errTrustSyntax}, + + // A misspelled Statement field (as opposed to an unrecognized + // Condition operator) is caught earlier, inside + // Document.UnmarshalJSON's strict Statement decoding - reached + // through ParseTrust's own top-level json.Unmarshal - so it + // surfaces as errTrustInvalidJSON, not errTrustSyntax. + {"misspelled Condition field is rejected, not silently ignored", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Conditon":{"StringEquals":{"aws:username":"alice"}}}]}`, errTrustInvalidJSON}, } for _, tt := range tests { diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go index 8e14b06b..3987897b 100644 --- a/iamapi/policy/validate.go +++ b/iamapi/policy/validate.go @@ -120,8 +120,10 @@ func (d Document) Validate() error { // Validate checks s against IAM policy statement grammar: a valid Effect, // no Principal/NotPrincipal, an Action or NotAction (not both) with -// vendor-prefixed values, and a Resource or NotResource (not both) with -// ARN-shaped values. Condition is not modeled or validated. +// vendor-prefixed values, a Resource or NotResource (not both) with +// ARN-shaped values, and - if present - a Condition block whose operators +// are all recognized (see conditionShapeValid; condition *keys* and operand +// *values* are deliberately not validated here, matching AWS behavior). func (s Statement) Validate() error { switch s.Effect { case "Allow", "Deny": @@ -133,6 +135,10 @@ func (s Statement) Validate() error { return errPrincipalNotAllowed } + if !conditionShapeValid(s.Condition) { + return errSyntax + } + if len(s.Action) > 0 && len(s.NotAction) > 0 { return errSyntax } diff --git a/iamapi/policy/validate_test.go b/iamapi/policy/validate_test.go index 8019a6ee..3175d2e6 100644 --- a/iamapi/policy/validate_test.go +++ b/iamapi/policy/validate_test.go @@ -22,7 +22,6 @@ import ( "github.com/versity/versitygw/iamapi/iamerr" ) -// Every case below was verified against a live AWS IAM account. func TestValidate(t *testing.T) { tests := []struct { name string @@ -39,6 +38,19 @@ func TestValidate(t *testing.T) { {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil}, {"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil}, + {"valid condition, StringEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`, nil}, + {"valid condition, Null", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"Null":{"aws:username":"true"}}}]}`, nil}, + {"valid condition, Bool", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, nil}, + {"valid condition, NumericEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"NumericEquals":{"s3:max-keys":"5"}}}]}`, nil}, + {"valid condition, ArnLike", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}}]}`, nil}, + {"valid condition, ForAllValues-qualified operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"ForAllValues:StringEquals":{"aws:TagKeys":["a","b"]}}}]}`, nil}, + {"valid condition, recognized operator with an unmodeled key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:SomeRandomKey":"x"}}}]}`, nil}, + {"valid condition, empty object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{}}]}`, nil}, + + {"invalid condition, unrecognized operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, errSyntax}, + {"invalid condition, malformed shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":"not an object"}]}`, errSyntax}, + {"invalid condition, NullIfExists", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"NullIfExists":{"aws:username":"true"}}}]}`, errSyntax}, + {"invalid json syntax", `{invalid json`, errSyntax}, {"empty object", `{}`, errSyntax}, {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, diff --git a/iamapi/policy/webidentity.go b/iamapi/policy/webidentity.go new file mode 100644 index 00000000..954acb93 --- /dev/null +++ b/iamapi/policy/webidentity.go @@ -0,0 +1,303 @@ +// 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 policy + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/versity/versitygw/debuglogger" +) + +// AssumeRoleWithWebIdentityAction is the sts action name role trust +// statements must (directly, or via a wildcard) authorize for +// AssumeRoleWithWebIdentity to succeed. +const AssumeRoleWithWebIdentityAction = "sts:AssumeRoleWithWebIdentity" + +// WebIdentityMatch is the outcome of evaluating a role's trust policy +// against an authenticated web identity federation attempt. The distinct +// NoPrincipal/NoIssuerMatch/ConditionFailed cases exist because AWS reports +// two different errors depending on which one occurs: NoPrincipal (no +// Federated principal in the trust policy resolves to a provider that +// actually exists) is reported as AccessDenied identically to a +// nonexistent role, while NoIssuerMatch and ConditionFailed (an existing, +// referenced provider's signing keys and claims were checked and didn't +// satisfy the request) are both reported as InvalidIdentityToken. +type WebIdentityMatch int + +const ( + NoPrincipal WebIdentityMatch = iota + NoIssuerMatch + ConditionFailed + ExplicitlyDenied + Allowed +) + +// ProviderLookup resolves a Federated principal ARN to the scheme-stripped +// Url of the OIDC provider it names, reporting ok=false for any ARN that +// doesn't correspond to a provider that actually exists. +type ProviderLookup func(federatedArn string) (url string, ok bool) + +// WebIdentityContext carries the token values needed to evaluate a trust +// statement's Condition block, keyed the way AWS's own condition context +// keys are: ":". +type WebIdentityContext struct { + ProviderURL string + // Audience is the token's effective audience: azp when present, + // otherwise the token's single aud value. Mapped to :aud. + Audience string + // OriginalAudience is the token's actual aud claim value(s), only ever + // set when azp is present (and therefore differs from Audience) — + // mapped to :oaud. This matters for Google hybrid + // clients, where aud names the backend project and azp names the + // actual OAuth client that requested the token. + OriginalAudience []string + Subject string + // Claims holds every other top-level string/string-array claim from + // the token, for Condition keys beyond aud/sub (e.g. a custom "amr" + // or "groups" claim). Values are pre-normalized to []string. + Claims map[string][]string + + // The remaining fields are request-scoped, not token-scoped: unlike + // Claims/Audience/Subject (all read from the presented JWT), these carry + // the same global request facts identity-policy Condition evaluation + // already sees (iammiddleware.requestConditionContext) so a trust + // statement's explicit Deny can be scoped by them too - a + // broad-Allow-plus-Deny trust policy must see the same request facts an + // Allow does, not treat the key as always absent. + + // SourceIP is the caller's address, mapped to aws:SourceIp. + SourceIP string + // Secure is whether the connection is TLS, mapped to + // aws:SecureTransport - AWS documents this key as present on every + // request, not just TLS ones. + Secure bool + // Now is the request's evaluation time, mapped to aws:CurrentTime and + // aws:EpochTime. + Now time.Time + // RoleSessionName is the caller-supplied RoleSessionName parameter, + // mapped to sts:RoleSessionName. + RoleSessionName string +} + +// conditionContext builds the map a trust statement's Condition block is +// evaluated against: ":" keys from the token itself, +// plus the request-scoped global keys identity-policy evaluation already +// exposes — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, +// aws:EpochTime, and sts:RoleSessionName — so an explicit Deny conditioned +// on any of these sees the same facts an Allow would. +func (w WebIdentityContext) conditionContext() map[string][]string { + ctxVars := make(map[string][]string, len(w.Claims)+8) + for claim, values := range w.Claims { + ctxVars[w.ProviderURL+":"+claim] = values + } + if w.Audience != "" { + ctxVars[w.ProviderURL+":aud"] = []string{w.Audience} + } + if len(w.OriginalAudience) > 0 { + ctxVars[w.ProviderURL+":oaud"] = w.OriginalAudience + } + if w.Subject != "" { + ctxVars[w.ProviderURL+":sub"] = []string{w.Subject} + } + if w.SourceIP != "" { + ctxVars["aws:SourceIp"] = []string{w.SourceIP} + } + ctxVars["aws:SecureTransport"] = []string{strconv.FormatBool(w.Secure)} + if !w.Now.IsZero() { + ctxVars["aws:CurrentTime"] = []string{w.Now.Format(time.RFC3339)} + ctxVars["aws:EpochTime"] = []string{strconv.FormatInt(w.Now.Unix(), 10)} + } + if w.RoleSessionName != "" { + ctxVars["sts:RoleSessionName"] = []string{w.RoleSessionName} + } + return ctxVars +} + +// EvaluateWebIdentityTrust evaluates document (a role's +// AssumeRolePolicyDocument) against wctx, resolving each statement's +// Federated principal(s) via lookup. +// +// The evaluation order mirrors AWS's observed behavior: first, whether any +// statement's Federated principal resolves to a provider that actually +// exists (regardless of whether its Url matches the token) determines +// NoPrincipal vs the later cases; only among statements whose provider +// exists AND whose Url matches wctx.ProviderURL does the token's Condition +// get evaluated. An explicit Deny statement matching the same provider, +// action and condition overrides an otherwise-matching Allow. +func EvaluateWebIdentityTrust(document string, lookup ProviderLookup, wctx WebIdentityContext) (WebIdentityMatch, string) { + var doc Document + if err := json.Unmarshal([]byte(document), &doc); err != nil { + debuglogger.Logf("role trust policy document failed to parse: %v", err) + return NoPrincipal, "" + } + // CreateRole/UpdateAssumeRolePolicy already reject a trust document that + // wouldn't pass ValidateTrust at write time, but a document stored + // before that validation existed could still fail it. Assign no meaning + // to a document AWS itself would reject — NoPrincipal is the same safe + // default an unresolvable Federated principal produces, reported as + // AccessDenied identically to a nonexistent role. + if err := doc.ValidateTrust(); err != nil { + debuglogger.Logf("role trust policy document failed validation: %v", err) + return NoPrincipal, "" + } + + ctxVars := wctx.conditionContext() + + anyExistingPrincipal := false + anyIssuerMatch := false + var allowedProviderArn string + allowed := false + denied := false + + for _, stmt := range doc.Statement { + if stmt.Effect != "Allow" && stmt.Effect != "Deny" { + continue + } + if !statementCoversAction(stmt, AssumeRoleWithWebIdentityAction) { + continue + } + + for _, federatedArn := range federatedPrincipals(stmt.Principal) { + url, ok := lookup(federatedArn) + if !ok { + continue + } + anyExistingPrincipal = true + if url != wctx.ProviderURL { + continue + } + anyIssuerMatch = true + + matched, condOk := evaluateCondition(stmt.Condition, ctxVars, doc.Version) + if !condOk { + debuglogger.Logf("web identity trust evaluation: statement condition could not be evaluated, denying") + denied = true + continue + } + if !matched { + continue + } + + if stmt.Effect == "Deny" { + denied = true + continue + } + allowed = true + allowedProviderArn = federatedArn + } + } + + switch { + case denied: + debuglogger.Logf("web identity trust evaluation: explicitly denied by trust policy") + return ExplicitlyDenied, "" + case allowed: + return Allowed, allowedProviderArn + case anyIssuerMatch: + debuglogger.Logf("web identity trust evaluation: provider %q matched but condition block did not", wctx.ProviderURL) + return ConditionFailed, "" + case anyExistingPrincipal: + debuglogger.Logf("web identity trust evaluation: no trust statement's provider matches issuer %q", wctx.ProviderURL) + return NoIssuerMatch, "" + default: + debuglogger.Logf("web identity trust evaluation: no trust statement resolves to an existing provider") + return NoPrincipal, "" + } +} + +// federatedPrincipals extracts a statement's Principal.Federated value(s), +// tolerating both a bare string and an array (empty/absent on any parse +// failure, since a statement whose Principal doesn't parse simply matches +// nothing here — CreateRole/UpdateAssumeRolePolicy already reject any +// trust policy that wouldn't parse this way). +func federatedPrincipals(raw json.RawMessage) []string { + if len(raw) == 0 { + return nil + } + var principal map[string]StringOrSlice + if err := json.Unmarshal(raw, &principal); err != nil { + return nil + } + return principal["Federated"] +} + +// statementCoversAction reports whether stmt's Action/NotAction authorizes +// action. +func statementCoversAction(stmt Statement, action string) bool { + if len(stmt.Action) > 0 { + return matchAny(stmt.Action, action) + } + if len(stmt.NotAction) > 0 { + return !matchAny(stmt.NotAction, action) + } + return false +} + +func matchAny(patterns []string, action string) bool { + for _, p := range patterns { + if matchActionPattern(p, action) { + return true + } + } + return false +} + +// matchActionPattern matches action against pattern, a case-insensitive +// IAM-style glob ('*' any run of characters, '?' any single character) — +// e.g. "sts:*" or "sts:AssumeRole*" both match "sts:AssumeRoleWithWebIdentity". +func matchActionPattern(pattern, action string) bool { + return globMatch(toLowerASCII(pattern), toLowerASCII(action)) +} + +func toLowerASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// globMatch implements the small wildcard grammar IAM Action/Resource +// patterns use: '*' matches any run of characters (including none), '?' +// matches exactly one character, everything else matches literally. +func globMatch(pattern, s string) bool { + var pi, si, star, match int + star = -1 + for si < len(s) { + switch { + case pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == s[si]): + pi++ + si++ + case pi < len(pattern) && pattern[pi] == '*': + star = pi + match = si + pi++ + case star != -1: + pi = star + 1 + match++ + si = match + default: + return false + } + } + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + return pi == len(pattern) +} diff --git a/iamapi/policy/webidentity_test.go b/iamapi/policy/webidentity_test.go new file mode 100644 index 00000000..46aa2290 --- /dev/null +++ b/iamapi/policy/webidentity_test.go @@ -0,0 +1,296 @@ +// 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 policy + +import "testing" + +const testProviderArn = "arn:aws:iam::000000000000:oidc-provider/example.com" +const otherProviderArn = "arn:aws:iam::000000000000:oidc-provider/other.com" + +// existingProviders resolves testProviderArn -> "example.com" and +// otherProviderArn -> "other.com"; any other ARN reports not-found, +// modeling a dangling trust-policy reference to a provider that was never +// created (or has since been deleted). +func existingProviders(arn string) (string, bool) { + switch arn { + case testProviderArn: + return "example.com", true + case otherProviderArn: + return "other.com", true + default: + return "", false + } +} + +func TestEvaluateWebIdentityTrust(t *testing.T) { + tests := []struct { + name string + document string + wctx WebIdentityContext + wantResult WebIdentityMatch + wantArn string + }{ + { + name: "simple allow, no condition", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "wildcard action matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:*"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "action does not match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRole"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + name: "dangling federated reference to a provider that doesn't exist", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + name: "existing provider referenced but issuer doesn't match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "unregistered.example.com"}, + wantResult: NoIssuerMatch, + }, + { + name: "condition matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "client1"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "condition does not match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "wrong-client"}, + wantResult: ConditionFailed, + }, + { + name: "explicit deny overrides matching allow", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Deny","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: ExplicitlyDenied, + }, + { + name: "deny for a different provider does not affect allow for this one", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Deny","Principal":{"Federated":"` + otherProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "second statement matches when first references a different provider", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + otherProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "malformed document", + document: `not json`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + // A Condition operator this package doesn't recognize (simulating + // a legacy document stored before write-time validation existed) + // must deny rather than being silently skipped or evaluated. The + // ValidateTrust re-check catches this before per-statement + // evaluation even runs, reported as NoPrincipal - the same + // "assign no meaning to an invalid document" outcome as an + // unresolvable Federated principal, and mapped to the identical + // AccessDenied response as ExplicitlyDenied by the controller. + name: "unrecognized operator on a matching statement denies", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"FooBarOperator":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "client1"}, + wantResult: NoPrincipal, + }, + { + // Claims are genuinely multivalued in production (a token can + // carry a "groups": ["admin","banned"] claim), unlike + // RequestContext.Condition on the identity-policy side - this + // is the most realistic place to exercise the multivalue + // aggregation semantics documented on aggregate() in + // condition.go. "banned" is present among the claim's values, + // so unqualified StringNotEquals (pre-existing, unchanged + // semantics: fails to match if any actual value matches) fails + // to match, and the Allow's condition doesn't hold. + name: "StringNotEquals against a genuinely multivalued claim doesn't match when any value matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringNotEquals":{"example.com:groups":"banned"}}}]}`, + wctx: WebIdentityContext{ + ProviderURL: "example.com", + Claims: map[string][]string{"groups": {"admin", "banned"}}, + }, + wantResult: ConditionFailed, + }, + { + name: "Null operator against a claim that's present", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Null":{"example.com:amr":"false"}}}]}`, + wctx: WebIdentityContext{ + ProviderURL: "example.com", + Claims: map[string][]string{"amr": {"mfa"}}, + }, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "Null operator against a claim that's absent", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Null":{"example.com:amr":"false"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: ConditionFailed, + }, + // A broad Allow plus an explicit Deny scoped to a global request key + // (aws:SourceIp, aws:SecureTransport, sts:RoleSessionName) must see + // the same request facts an Allow would, so a Deny relying on any + // of them overrides the broad Allow. + { + name: "Deny on aws:SourceIp applies when the caller's address matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"IpAddress":{"aws:SourceIp":"203.0.113.0/24"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", SourceIP: "203.0.113.5"}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on aws:SourceIp does not apply for a different address", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"IpAddress":{"aws:SourceIp":"203.0.113.0/24"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", SourceIP: "198.51.100.5"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "Deny on aws:SecureTransport=false applies to a plaintext request", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", Secure: false}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on sts:RoleSessionName applies when it matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"sts:RoleSessionName":"forbidden-session"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", RoleSessionName: "forbidden-session"}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on sts:RoleSessionName does not apply for a different session name", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"sts:RoleSessionName":"forbidden-session"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", RoleSessionName: "allowed-session"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, arn := EvaluateWebIdentityTrust(tt.document, existingProviders, tt.wctx) + if result != tt.wantResult { + t.Errorf("result = %v, want %v", result, tt.wantResult) + } + if arn != tt.wantArn { + t.Errorf("providerArn = %q, want %q", arn, tt.wantArn) + } + }) + } +} + +func TestMatchActionPattern(t *testing.T) { + tests := []struct { + pattern string + action string + want bool + }{ + {pattern: "sts:AssumeRoleWithWebIdentity", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:*", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:AssumeRole*", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "STS:ASSUMEROLEWITHWEBIDENTITY", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:AssumeRole", action: "sts:AssumeRoleWithWebIdentity", want: false}, + {pattern: "iam:*", action: "sts:AssumeRoleWithWebIdentity", want: false}, + {pattern: "sts:AssumeRoleWithWebIdentit?", action: "sts:AssumeRoleWithWebIdentity", want: true}, + } + for _, tt := range tests { + if got := matchActionPattern(tt.pattern, tt.action); got != tt.want { + t.Errorf("matchActionPattern(%q, %q) = %v, want %v", tt.pattern, tt.action, got, tt.want) + } + } +} diff --git a/iamapi/response.go b/iamapi/response.go index ee799e09..0d4cf8b6 100644 --- a/iamapi/response.go +++ b/iamapi/response.go @@ -22,6 +22,7 @@ import ( "github.com/versity/versitygw/debuglogger" "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/types" "github.com/versity/versitygw/internal/httpctx" ) @@ -75,11 +76,17 @@ func ProcessController(ctx fiber.Ctx, controller ActionHandler) error { ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML) if apiErr, ok := err.(iamerr.APIError); ok { + if isSTSAction(ctx) { + apiErr = iamerr.WithNamespace(apiErr, iamerr.STSNamespace).(iamerr.APIError) + } return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID)) } debuglogger.InternalError(err) internalErr := iamerr.GetAPIError(iamerr.ErrInternalFailure) + if isSTSAction(ctx) { + internalErr.XMLNamespace = iamerr.STSNamespace + } return ctx.Status(internalErr.StatusCode()).Send(internalErr.XMLBody(requestID)) } @@ -121,6 +128,15 @@ func ProcessController(ctx fiber.Ctx, controller ActionHandler) error { return ctx.Status(status).Send(res) } +// isSTSAction reports whether the current request's Action is one of the +// STS actions sharing this IAM endpoint (see router.go's stsActions), +// which render both success and error responses under STS's own XML +// namespace rather than IAM's. +func isSTSAction(ctx fiber.Ctx) bool { + action, _ := iamutil.RequestParam(ctx, "Action") + return stsActions[action] +} + func SetResponseHeaders(ctx fiber.Ctx, headers map[string]*string) { if headers == nil { return diff --git a/iamapi/router.go b/iamapi/router.go index 430398fb..6820cd1c 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -22,14 +22,26 @@ import ( "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/internal/sigv4auth" ) const ( - iamAPIVersion = "2010-05-08" - noVersionSpecified = "NO_VERSION_SPECIFIED" - productURL = "https://www.versity.com/products/versitygw/" + iamAPIVersion = "2010-05-08" + stsAPIVersion = "2011-06-15" + noVersionSpecified = "NO_VERSION_SPECIFIED" + productURL = "https://www.versity.com/products/versitygw/" + actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity" ) +// stsActions are routed through this same IAM endpoint but, being real STS +// actions, are versioned against stsAPIVersion rather than iamAPIVersion — +// and (see response.go's ProcessController) render under STS's own XML +// namespace rather than IAM's. +var stsActions = map[string]bool{ + "AssumeRoleWithWebIdentity": true, + "GetCallerIdentity": true, +} + var unknownOperationBody = []byte("\n") type IAMApiRouter struct { @@ -83,11 +95,34 @@ func (r *IAMApiRouter) Init() { "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, + // STS actions (routed through this same endpoint; see stsActions) + "AssumeRoleWithWebIdentity": r.Ctrl.AssumeRoleWithWebIdentity, + "GetCallerIdentity": r.Ctrl.GetCallerIdentity, } - actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) - r.app.Get("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute) - r.app.Post("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute) + iamRoute := ProcessHandlers(r.routeAction, + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceIAM, r.rootCreds, r.store), + iammiddleware.VerifyIAMPolicy(r.store), + ) + stsAuthRoute := ProcessHandlers(r.routeAction, + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceSTS, r.rootCreds, r.store), + ) + stsOpenRoute := ProcessHandlers(r.routeAction) + + dispatch := func(ctx fiber.Ctx) error { + action, _ := iamutil.RequestParam(ctx, "Action") + switch { + case action == actionAssumeRoleWithWebIdentity: + return stsOpenRoute(ctx) + case stsActions[action]: + return stsAuthRoute(ctx) + default: + return iamRoute(ctx) + } + } + + r.app.Get("/*", iamutil.MatchQueryOrFormArgs("Action"), dispatch) + r.app.Post("/*", iamutil.MatchQueryOrFormArgs("Action"), dispatch) r.app.All("/", r.redirectRoot) r.app.All("*", r.unknownOperation) @@ -99,7 +134,12 @@ func (r *IAMApiRouter) routeAction(ctx fiber.Ctx) (*Response, error) { if !versionSpecified { version = noVersionSpecified } - if version != iamAPIVersion { + + expectedVersion := iamAPIVersion + if stsActions[action] { + expectedVersion = stsAPIVersion + } + if version != expectedVersion { return &Response{}, iamerr.InvalidAction(action, version) } diff --git a/iamapi/server.go b/iamapi/server.go index 43660610..43d09a2c 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -102,6 +102,9 @@ func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | vgw-iam | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 70cc7ac1..27acfc3a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -69,6 +69,11 @@ type iamConfig struct { // stripped, exactly as given at creation — no index needed since // lookup is by exact string, not a case-insensitive human name). OIDCProviders map[string]types.OIDCProvider `json:"oidcProviders"` + + // Sessions is keyed by AccessKeyId. Entries whose Expiration has + // passed are pruned opportunistically whenever a new session is + // created (see pruneExpiredSessions), rather than on a timer. + Sessions map[string]types.Session `json:"sessions"` } func defaultIAMConfig() iamConfig { @@ -79,6 +84,7 @@ func defaultIAMConfig() iamConfig { Roles: map[string]types.Role{}, RoleNameIndex: map[string]string{}, OIDCProviders: map[string]types.OIDCProvider{}, + Sessions: map[string]types.Session{}, } } @@ -115,6 +121,10 @@ func normalizeIAMConfig(conf *iamConfig) { if conf.OIDCProviders == nil { conf.OIDCProviders = make(map[string]types.OIDCProvider) } + + if conf.Sessions == nil { + conf.Sessions = make(map[string]types.Session) + } } // lookupUser resolves name to the canonical stored user name and entry, @@ -213,6 +223,26 @@ func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User return cloneUser(user), nil } +func (s *InternalStore) GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) { + s.RLock() + conf, err := s.engine.GetIAM() + if err != nil { + s.RUnlock() + return nil, err + } + username, ok := conf.AccessKeyIndex[accessKeyID] + s.RUnlock() + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + return user, nil +} + func (s *InternalStore) ListUsers(_ context.Context, input ListUsersInput) (*ListUsersOutput, error) { s.RLock() defer s.RUnlock() @@ -464,6 +494,45 @@ func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID stri return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } +func (s *InternalStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) 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 + } + + username, ok := conf.AccessKeyIndex[accessKeyID] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + user.AccessKeys[i].LastUsedDate = when + user.AccessKeys[i].LastUsedService = service + user.AccessKeys[i].LastUsedRegion = region + found = true + break + } + } + if !found { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + conf.Users[username] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { s.RLock() defer s.RUnlock() @@ -1166,6 +1235,72 @@ func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn stri return unwrapAPIError(err) } +func (s *InternalStore) CreateSession(_ context.Context, session types.Session) (*types.Session, 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 + } + + pruneExpiredSessions(conf, session.CreateDate) + if activeSessionCountForRole(conf, session.RoleArn) >= MaxActiveSessionsPerRole { + return nil, iamerr.GetAPIError(iamerr.ErrThrottling) + } + conf.Sessions[session.AccessKeyId] = session + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + cloned := session + return &cloned, nil +} + +// activeSessionCountForRole counts conf's sessions belonging to roleArn. +// Called after pruneExpiredSessions, so this only ever counts sessions that +// are still actually active. +func activeSessionCountForRole(conf iamConfig, roleArn string) int { + count := 0 + for _, sess := range conf.Sessions { + if sess.RoleArn == roleArn { + count++ + } + } + return count +} + +func (s *InternalStore) GetSession(_ context.Context, accessKeyID string) (*types.Session, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + session, ok := conf.Sessions[accessKeyID] + if !ok || !session.Expiration.After(time.Now().UTC()) { + return nil, ErrSessionNotFound + } + + cloned := session + return &cloned, nil +} + +// pruneExpiredSessions removes every session whose Expiration is at or +// before now. Called from CreateSession so the sessions map never grows +// unbounded, without needing a separate timer/goroutine. +func pruneExpiredSessions(conf iamConfig, now time.Time) { + for accessKeyID, session := range conf.Sessions { + if !session.Expiration.After(now) { + delete(conf.Sessions, accessKeyID) + } + } +} + func cloneOIDCProvider(p types.OIDCProvider) *types.OIDCProvider { cloned := p cloned.ClientIDList = slices.Clone(p.ClientIDList) diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 7f0b9a73..28aa5a8d 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -45,10 +45,27 @@ const MaxClientIDsPerOIDCProvider = 100 // single account may hold const MaxOIDCProvidersPerAccount = 100 +// MaxActiveSessionsPerRole bounds how many currently-unexpired +// AssumeRoleWithWebIdentity sessions a single role may have at once. +// AWS manages and rate-limits STS as a hosted service with no +// customer-visible equivalent quota to match for fidelity; this exists +// purely as local resource protection, since without it a single valid +// federated token can be replayed indefinitely to grow the session +// store — every InternalStore rewrite, or Vault KV path/metadata entry — +// without bound. Chosen generously enough to not constrain any legitimate +// workload's concurrent session count. +// +// A var, not a const, so tests can temporarily lower it rather than paying +// the cost of actually creating 1000 sessions to exercise the cap. +var MaxActiveSessionsPerRole = 1000 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") + // ErrSessionNotFound is returned by GetSession when accessKeyID names no + // session, or names one whose Expiration has already passed. + ErrSessionNotFound = errors.New("iamapi: session not found") ) type ListUsersInput struct { @@ -165,6 +182,7 @@ 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) + GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) @@ -173,6 +191,12 @@ type Storer interface { DeleteAccessKey(ctx context.Context, username, accessKeyID string) error GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) + // RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed + // metadata (service, region, and timestamp) to reflect a successful + // authentication at when. Called best-effort/asynchronously by the auth + // middleware, so implementations should treat a lost update under + // concurrent use as acceptable rather than something worth retrying hard. + RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) @@ -198,6 +222,9 @@ type Storer interface { AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error + + CreateSession(ctx context.Context, session types.Session) (*types.Session, error) + GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index 59c95a56..cbf32af4 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -17,6 +17,7 @@ package storage import ( "context" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -93,7 +94,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { { Path: "/engineering/", UserName: "alice", - UserID: "AIDA22222222222222222", + UserID: "AIDAx2222222222222222", Arn: "arn:aws:iam::000000000000:user/engineering/alice", CreateDate: created, Tags: []types.Tag{ @@ -104,14 +105,14 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { { Path: "/engineering/platform/", UserName: "bob", - UserID: "AIDA33333333333333333", + UserID: "AIDAx3333333333333333", Arn: "arn:aws:iam::000000000000:user/engineering/platform/bob", CreateDate: created.Add(time.Second), }, { Path: "/ops/", UserName: "carol", - UserID: "AIDA44444444444444444", + UserID: "AIDAx4444444444444444", Arn: "arn:aws:iam::000000000000:user/ops/carol", CreateDate: created.Add(2 * time.Second), }, @@ -200,7 +201,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { if _, err := reopened.CreateAccessKey(ctx, CreateAccessKeyInput{ UserName: "zoe", - AccessKeyID: "AKIAZZZZZZZZZZZZZZZZ", + AccessKeyID: "AKIAzZZZZZZZZZZZZZZZ", SecretAccessKey: "secret", Status: "Active", CreateDate: created, @@ -210,7 +211,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflict)) { t.Fatalf("DeleteUser with access keys err = %v, want DeleteConflict", err) } - if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAZZZZZZZZZZZZZZZZ"); err != nil { + if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAzZZZZZZZZZZZZZZZ"); err != nil { t.Fatalf("DeleteAccessKey: %v", err) } @@ -222,6 +223,39 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { } } +func TestInternalStoreGetUserByAccessKeyID(t *testing.T) { + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateAccessKey(ctx, CreateAccessKeyInput{ + UserName: "alice", + AccessKeyID: "AKIAALICE0000000000", + SecretAccessKey: "secret", + Status: "Active", + CreateDate: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + got, err := store.GetUserByAccessKeyID(ctx, "AKIAALICE0000000000") + if err != nil { + t.Fatalf("GetUserByAccessKeyID: %v", err) + } + if got.UserName != "alice" { + t.Fatalf("GetUserByAccessKeyID = %#v, want alice", got) + } + + if _, err := store.GetUserByAccessKeyID(ctx, "AKIAuNKNOWN0000000000"); !errors.Is(err, iamerr.NoSuchEntityAccessKey("AKIAuNKNOWN0000000000")) { + t.Fatalf("GetUserByAccessKeyID unknown key err = %v, want NoSuchEntityAccessKey", err) + } +} + func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { ctx := context.Background() store, err := NewInternal(t.TempDir()) @@ -229,10 +263,10 @@ func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { t.Fatalf("NewInternal: %v", err) } - if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDA11111111111111111"}); err != nil { + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil { t.Fatalf("CreateUser: %v", err) } - if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDA22222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { + if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDAx2222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err) } @@ -265,7 +299,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/engineering/", RoleName: "alice-role", - RoleID: "AROA22222222222222222", + RoleID: "AROAx2222222222222222", Arn: "arn:aws:iam::000000000000:role/engineering/alice-role", CreateDate: created, AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -277,7 +311,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/engineering/platform/", RoleName: "bob-role", - RoleID: "AROA33333333333333333", + RoleID: "AROAx3333333333333333", Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role", CreateDate: created.Add(time.Second), AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -286,7 +320,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/ops/", RoleName: "carol-role", - RoleID: "AROA44444444444444444", + RoleID: "AROAx4444444444444444", Arn: "arn:aws:iam::000000000000:role/ops/carol-role", CreateDate: created.Add(2 * time.Second), AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -306,7 +340,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) { t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err) } - if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROA55555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { + if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROAx5555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err) } duplicateID := roles[2] @@ -395,7 +429,7 @@ func TestInternalStoreRolePolicyCRUD(t *testing.T) { if _, err := store.CreateRole(ctx, types.Role{ RoleName: "alice-role", - RoleID: "AROA22222222222222222", + RoleID: "AROAx2222222222222222", AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, }); err != nil { t.Fatalf("CreateRole: %v", err) @@ -511,3 +545,136 @@ func TestInternalStoreRolePolicyCRUD(t *testing.T) { t.Fatalf("DeleteRole after removing all policies: %v", err) } } + +func TestInternalStoreSessionCRUDAndExpiry(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + // GetSession compares Expiration against the real wall clock, so (unlike + // most other timestamps in this package's tests) now must track it. + now := time.Now().UTC() + session := types.Session{ + AccessKeyId: "ASIAeXAMPLE1234567890", + SecretAccessKey: "secret", + SessionToken: "token", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAeXAMPLE1234567890", + RoleSessionName: "my-session", + Provider: "arn:aws:iam::000000000000:oidc-provider/example.com", + Audience: "client1", + Subject: "user1", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + + if _, err := store.CreateSession(ctx, session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + got, err := store.GetSession(ctx, session.AccessKeyId) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if !reflect.DeepEqual(*got, session) { + t.Fatalf("GetSession = %#v, want %#v", *got, session) + } + + if _, err := store.GetSession(ctx, "ASIAUNKNOWN"); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("GetSession unknown access key err = %v, want ErrSessionNotFound", err) + } + + // A session persists across process restarts (round-trips through the + // same on-disk file the rest of the IAM store uses). + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + if _, err := reopened.GetSession(ctx, session.AccessKeyId); err != nil { + t.Fatalf("GetSession after reopen: %v", err) + } + + expired := types.Session{ + AccessKeyId: "ASIAeXPIRED1234567890", + CreateDate: now, + Expiration: now.Add(-time.Minute), + } + if _, err := reopened.CreateSession(ctx, expired); err != nil { + t.Fatalf("CreateSession expired: %v", err) + } + if _, err := reopened.GetSession(ctx, expired.AccessKeyId); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("GetSession expired err = %v, want ErrSessionNotFound", err) + } + + // Creating a new session opportunistically prunes the already-expired + // one from storage rather than letting it accumulate forever. + another := types.Session{ + AccessKeyId: "ASIAaNOTHER1234567890", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := reopened.CreateSession(ctx, another); err != nil { + t.Fatalf("CreateSession another: %v", err) + } + internal := reopened.(*InternalStore) + conf, err := internal.engine.GetIAM() + if err != nil { + t.Fatalf("GetIAM: %v", err) + } + if _, ok := conf.Sessions[expired.AccessKeyId]; ok { + t.Fatalf("expired session %q was not pruned: %#v", expired.AccessKeyId, conf.Sessions) + } + if _, ok := conf.Sessions[another.AccessKeyId]; !ok { + t.Fatalf("unexpired session %q missing after prune: %#v", another.AccessKeyId, conf.Sessions) + } +} + +func TestInternalStoreSessionCapPerRole(t *testing.T) { + // Each CreateSession call rewrites the whole IAM file, so hitting the + // real 1000 cap here would mean O(n^2) JSON work just to prove the cap + // is enforced. Lower it for the duration of the test instead. + orig := MaxActiveSessionsPerRole + MaxActiveSessionsPerRole = 5 + t.Cleanup(func() { MaxActiveSessionsPerRole = orig }) + + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + now := time.Now().UTC() + newSession := func(i int, roleArn string) types.Session { + return types.Session{ + AccessKeyId: fmt.Sprintf("ASIACAPPEDROLE%06d", i), + RoleArn: roleArn, + CreateDate: now, + Expiration: now.Add(time.Hour), + } + } + + const roleArn = "arn:aws:iam::000000000000:role/capped-role" + for i := range MaxActiveSessionsPerRole { + if _, err := store.CreateSession(ctx, newSession(i, roleArn)); err != nil { + t.Fatalf("CreateSession %d: %v", i, err) + } + } + + // The role is now at its cap - one more session for the same role must + // be rejected rather than accepted unboundedly. + _, err = store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole, roleArn)) + var apiErr iamerr.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode() != 400 { + t.Fatalf("CreateSession at cap err = %v, want a Throttling APIError", err) + } + + // A different role is entirely unaffected by the first role's cap. + const otherRoleArn = "arn:aws:iam::000000000000:role/other-role" + if _, err := store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole+1, otherRoleArn)); err != nil { + t.Fatalf("CreateSession for a different role: %v", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 5b9d9694..db356bbf 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -28,6 +28,7 @@ import ( vault "github.com/hashicorp/vault-client-go" "github.com/hashicorp/vault-client-go/schema" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" @@ -35,6 +36,60 @@ import ( const vaultRequestTimeout = 10 * time.Second +// maxCASRetries bounds the read-modify-write retry loop withUserCAS/ +// withRoleCAS/withOIDCProviderCAS run when a version-checked (CAS) write +// loses a race against a concurrent writer updating the same entity — +// mirroring the 3-attempt collision-retry loops already used elsewhere in +// this package for ID generation (see controller.go's CreateUser/CreateRole/ +// CreateAccessKey). +const maxCASRetries = 3 + +// errConcurrentModification is withUserCAS/withRoleCAS/withOIDCProviderCAS's +// internal signal that a replace* call's CAS write lost a race against +// another writer and should be retried; it never escapes to a caller +// directly — once retries are exhausted it's surfaced as +// iamerr.ConcurrentModification(), matching real IAM's documented +// ConcurrentModificationException. +var errConcurrentModification = errors.New("iamapi: concurrent modification") + +// errRenameCleanupFailed marks an error from deleteOldUserAfterRename: the +// rename's new record was created successfully, but deleting the stale +// record at the old name failed even after retrying (see +// renameDeleteRetries). It is surfaced only via errors.Is/wrapping — +// Vault's KV store has no multi-key transaction to make the two writes +// atomic, so this signals a state that needs operator attention rather than +// one an automatic retry of the whole operation can resolve (a caller +// retrying UpdateUser from scratch would now fail with EntityAlreadyExists +// against the very record it just created). +var errRenameCleanupFailed = errors.New("iamapi: rename cleanup failed") + +// kvVersion extracts a KV v2 secret version from a read response's metadata +// map. The generated schema client types Metadata as map[string]interface{}, +// but vault-client-go decodes its JSON body with a decoder configured to +// produce json.Number for numeric fields, not float64 — a plain +// metadata["version"].(float64) assertion never matches, so it silently fell +// through to the zero value on every call. Every version-checked (CAS) +// write's readVersion was therefore always 0 — the "create if it doesn't +// exist yet" sentinel — so any write to an already-existing document (i.e. +// every one of them past its first) sent cas:0 and was unconditionally +// rejected by Vault as a check-and-set mismatch. That surfaced as +// ConcurrentModificationException on withUserCAS/withRoleCAS/ +// withOIDCProviderCAS's every retry, deterministically, with no concurrent +// writer involved at all — confirmed by reproducing it single-threaded +// against a live Vault (CreateRole then PutRolePolicy, nothing else +// touching the record, still failed every time before this fix). +func kvVersion(metadata map[string]any) int32 { + switch v := metadata["version"].(type) { + case json.Number: + if n, err := v.Int64(); err == nil { + return int32(n) + } + case float64: + return int32(v) + } + return 0 +} + // VaultConfig holds all configuration options for the Vault-backed IAM storer. type VaultConfig struct { EndpointURL string @@ -193,53 +248,41 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return nil } -// findUserKey resolves name to the exact stored KV path segment (the -// original UserName casing used at creation), case-insensitively, by -// listing the users under secretStoragePath and comparing with EqualFold. -// AWS enforces case-insensitive UserName uniqueness but Vault's KV paths -// are plain case-sensitive strings, so a list+compare fallback is needed — -// KV has no native case-insensitive lookup. ok is false both when nothing -// matches and (harmlessly) when the prefix has no children at all. -func (s *VaultStore) findUserKey(name string) (string, bool, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return "", false, reauthErr - } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - return "", false, err - } - } - for _, key := range resp.Data.Keys { - if strings.EqualFold(key, name) { - return key, true, nil - } - } - return "", false, nil +// usersPath is the KV prefix under which users are stored, kept distinct +// from rolesPath/oidcProvidersPath/sessionsPath — mirroring their own +// isolation rationale — so listing users never picks up a sibling entity +// type's directory marker (e.g. "roles/") as if it were a username. +func (s *VaultStore) usersPath() string { + return s.secretStoragePath + "/users" +} + +// caseFoldKey case-folds name to the KV path segment (and inner data map +// key) an identity of that name is stored under. AWS enforces +// case-insensitive uniqueness for IAM names (UserName, RoleName) but +// Vault's KV paths are plain case-sensitive strings; storing every identity +// under its case-folded name — rather than the as-given casing, resolved by +// a separate list-and-compare lookup — makes uniqueness a property Vault's +// own CAS write enforces atomically, instead of a check-then-write race +// between two callers using different casings of the same name (e.g. +// "Alice" and "alice" both passing a list-based existence check and then +// both succeeding at CAS 0 on two different paths). The original, +// as-given casing is preserved in the identity's own UserName/RoleName +// field within the stored document. +func caseFoldKey(name string) string { + return strings.ToLower(name) } func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { - if _, ok, err := s.findUserKey(user.UserName); err != nil { - return nil, err - } else if ok { - return nil, iamerr.EntityAlreadyExistsUser(user.UserName) - } + key := caseFoldKey(user.UserName) userMap, err := userToVaultMap(user) if err != nil { return nil, fmt.Errorf("serialize user: %w", err) } - path := s.secretStoragePath + "/" + user.UserName + path := s.usersPath() + "/" + key req := schema.KvV2WriteRequest{ - Data: map[string]any{user.UserName: userMap}, + Data: map[string]any{key: userMap}, Options: map[string]any{ "cas": 0, }, @@ -268,56 +311,120 @@ func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User return cloneUser(user), nil } +// DeleteUser checks user against its dependency preconditions (no inline +// policies, no access keys) and then deletes it. The metadata-delete call +// Vault exposes has no CAS parameter of its own (unlike a KV write), so a +// plain read-check-then-delete would leave a window where a concurrent +// CreateAccessKey or PutUserPolicy lands between the check and the delete, +// and the delete proceeds anyway, orphaning the new key/policy against a +// user that no longer exists. Closing that window: after the +// dependency check, replaceUser writes the same (unchanged) record back +// with a CAS matching the version just read — succeeding only if nothing +// else has modified the record since — immediately before the actual +// delete, shrinking the race to the gap between two back-to-back Vault +// calls instead of the whole request lifecycle. A CAS conflict there means +// something changed after the check, so the whole check is retried +// (bounded by maxCASRetries) rather than deleting against stale +// information. func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { - user, err := s.GetUser(ctx, username) - if err != nil { - return err + for range maxCASRetries { + user, version, err := s.readUserVersion(username) + if err != nil { + return err + } + if len(user.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + if len(user.AccessKeys) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } + + if _, err := s.replaceUser(ctx, *user, version); err != nil { + if errors.Is(err, errConcurrentModification) { + continue + } + return err + } + + return s.deleteByPath("users/" + caseFoldKey(user.UserName)) } - if len(user.Policies.Inline) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) - } - if len(user.AccessKeys) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflict) - } - return s.deleteByPath(user.UserName) + return iamerr.ConcurrentModification() } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - canonical, ok, err := s.findUserKey(username) - if err != nil { - return nil, err - } - if !ok { - return nil, iamerr.NoSuchEntityUser(username) - } + user, _, err := s.readUserVersion(username) + return user, err +} - path := s.secretStoragePath + "/" + canonical +// readUserVersion resolves username the same way GetUser does, additionally +// returning the KV version the record was read at, so a mutation can write +// back with a matching CAS value instead of racing on a blind +// delete-then-recreate (see replaceUser). +func (s *VaultStore) readUserVersion(username string) (*types.User, int32, error) { + key := caseFoldKey(username) + path := s.usersPath() + "/" + key 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, 0, iamerr.NoSuchEntityUser(username) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, 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, 0, iamerr.NoSuchEntityUser(username) + } + return nil, 0, err + } + } + + user, err := parseVaultUser(resp.Data.Data, key) + if err != nil { + return nil, 0, err + } + return cloneUser(user), kvVersion(resp.Data.Metadata), nil +} + +// GetUserByAccessKeyID has no index to consult (unlike InternalStore's +// AccessKeyIndex) so it scans every user's access keys, mirroring +// GetAccessKeyLastUsed's existing linear scan. +func (s *VaultStore) GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } return nil, err } } - user, err := parseVaultUser(resp.Data.Data, canonical) - if err != nil { - return nil, err + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, err + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return user, nil + } + } } - return cloneUser(user), nil + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return &ListUsersOutput{Users: []types.User{}}, nil @@ -329,7 +436,7 @@ func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*List } return nil, reauthErr } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return &ListUsersOutput{Users: []types.User{}}, nil @@ -384,7 +491,7 @@ func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*List } func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) { - user, err := s.GetUser(ctx, input.UserName) + user, version, err := s.readUserVersion(input.UserName) if err != nil { return nil, err } @@ -415,112 +522,194 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != originalName { - // Create at new path first to detect conflicts before deleting the old entry. + if caseFoldKey(user.UserName) != caseFoldKey(originalName) { + // A genuine rename to a different case-folded key (and therefore a + // different KV path): create at the new path first — its cas:0 + // write atomically detects a conflict, including one from a + // concurrent create/rename racing for the same new name — before + // deleting the old entry. A UserName change that's case-only (e.g. + // "Alice" -> "alice") case-folds to the *same* path, so it's handled + // below as an in-place update instead: routing it through + // CreateUser here would spuriously fail with EntityAlreadyExists + // against the very record being renamed. if _, err := s.CreateUser(ctx, *user); err != nil { return nil, err } - if err := s.deleteByPath(originalName); err != nil { + if err := s.deleteOldUserAfterRename(originalName); err != nil { return nil, err } - } else if _, err := s.replaceUser(ctx, *user); err != nil { + } else if _, err := s.replaceUser(ctx, *user, version); err != nil { return nil, err } return cloneUser(*user), nil } -// replaceUser overwrites the stored document for user.UserName by deleting -// all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceUser(ctx context.Context, user types.User) (*types.User, error) { - if err := s.deleteByPath(user.UserName); err != nil { - return nil, err +// renameDeleteRetries bounds deleteOldUserAfterRename's retries of the +// old-path delete that follows a successful create-at-new-path during a +// rename (roles have no rename operation, so only users need this). Vault +// has no multi-key transaction to make "create new, delete old" atomic, so +// a delete failure here (after the new record already exists) is the one +// window where two live records for the same identity can coexist; +// retrying a bounded number of times, with a short backoff, absorbs a +// transient failure (network blip, momentary 403) rather than leaving that +// window open on the first error. +const ( + renameDeleteRetries = 3 + renameDeleteBackoff = 200 * time.Millisecond +) + +// deleteOldUserAfterRename deletes the pre-rename user record at +// originalName after UpdateUser has already created the record at its new +// name, retrying up to renameDeleteRetries times. If every attempt fails, +// the error returned wraps errRenameCleanupFailed so callers/operators can +// recognize that the new record was created and the stale record at +// originalName still exists and needs manual removal — better than +// masking that state as an ordinary write error. +func (s *VaultStore) deleteOldUserAfterRename(originalName string) error { + var err error + for attempt := range renameDeleteRetries { + if attempt > 0 { + time.Sleep(renameDeleteBackoff) + } + if err = s.deleteByPath("users/" + caseFoldKey(originalName)); err == nil { + return nil + } } - return s.CreateUser(ctx, user) + return fmt.Errorf("%w: stale user record %q must be removed manually: %v", errRenameCleanupFailed, originalName, err) +} + +// replaceUser overwrites the stored document for user.UserName using a +// version-checked (CAS) write tied to readVersion — the KV version the +// caller most recently read the record at — instead of an unconditional +// delete-then-recreate. This way, two concurrent updates to the same user +// (e.g. a DeleteAccessKey revocation racing a PutUserPolicy call) can't +// have the second writer silently discard the first writer's change: a CAS +// mismatch fails with errConcurrentModification, for withUserCAS to retry. +func (s *VaultStore) replaceUser(ctx context.Context, user types.User, readVersion int32) (*types.User, error) { + userMap, err := userToVaultMap(user) + if err != nil { + return nil, fmt.Errorf("serialize user: %w", err) + } + + key := caseFoldKey(user.UserName) + path := s.usersPath() + "/" + key + req := schema.KvV2WriteRequest{ + Data: map[string]any{key: userMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + return nil, err + } + } + return cloneUser(user), nil +} + +// withUserCAS resolves username, applies mutate to the fetched user, and +// writes it back with a CAS matching the version it was read at, retrying +// (bounded by maxCASRetries) if a concurrent writer's update lands first — +// closing the lost-update race described in replaceUser's doc comment. +// mutate's own error (e.g. a quota or not-found error) is returned +// immediately, never retried — only a genuine CAS conflict is. +func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate func(*types.User) error) (*types.User, error) { + for range maxCASRetries { + user, version, err := s.readUserVersion(username) + if err != nil { + return nil, err + } + if err := mutate(user); err != nil { + return nil, err + } + result, err := s.replaceUser(ctx, *user, version) + if err == nil { + return result, nil + } + if !errors.Is(err, errConcurrentModification) { + return nil, err + } + } + return nil, iamerr.ConcurrentModification() } func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return nil, err - } - - if len(user.AccessKeys) >= MaxAccessKeysPerUser { - return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) - } - for _, key := range user.AccessKeys { - if key.AccessKeyId == input.AccessKeyID { - return nil, ErrAccessKeyIDAlreadyExists + var created types.AccessKey + if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + return ErrAccessKeyIDAlreadyExists + } } - } - user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ - AccessKeyId: input.AccessKeyID, - SecretAccessKey: input.SecretAccessKey, - Status: input.Status, - CreateDate: input.CreateDate, - }) - - if _, err := s.replaceUser(ctx, *user); err != nil { + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + created = types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + } + return nil + }); err != nil { return nil, err } - return &types.AccessKey{ - UserName: input.UserName, - AccessKeyId: input.AccessKeyID, - Status: input.Status, - SecretAccessKey: input.SecretAccessKey, - CreateDate: input.CreateDate, - }, nil + return &created, nil } func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return err - } - - found := false - for i, key := range user.AccessKeys { - if key.AccessKeyId == input.AccessKeyID { - user.AccessKeys[i].Status = input.Status - found = true - break + _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + return nil + } } - } - if !found { return iamerr.NoSuchEntityAccessKey(input.AccessKeyID) - } - - _, err = s.replaceUser(ctx, *user) + }) return err } func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error { - user, err := s.GetUser(ctx, username) - if err != nil { - return err - } - - idx := -1 - for i, key := range user.AccessKeys { - if key.AccessKeyId == accessKeyID { - idx = i - break + _, err := s.withUserCAS(ctx, username, func(user *types.User) error { + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityAccessKey(accessKeyID) - } - - user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) - - _, err = s.replaceUser(ctx, *user) + if idx == -1 { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + return nil + }) return err } func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) @@ -528,7 +717,7 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { return nil, reauthErr } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) @@ -545,7 +734,7 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin for _, key := range user.AccessKeys { if key.AccessKeyId == accessKeyID { return &GetAccessKeyLastUsedOutput{ - UserName: username, + UserName: user.UserName, LastUsedDate: key.LastUsedDate, ServiceName: key.LastUsedService, Region: key.LastUsedRegion, @@ -557,6 +746,74 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } +// recordAccessKeyUsageTimeout bounds RecordAccessKeyUsage's detached +// background update. +const recordAccessKeyUsageTimeout = 5 * time.Second + +// RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed metadata +// in its own background goroutine, detached from ctx, and always returns +// nil immediately: this runs on the hot path of every authenticated request +// (see iammiddleware.recordAccessKeyUsage), and a Vault round trip — plus, +// on a CAS conflict, withUserCAS's retry loop — is too expensive to add +// synchronously to every one of them. A failure (including one that +// exhausts those retries) is only logged, never surfaced: this is purely +// informational metadata, and a lost update under concurrent use is +// immaterial. +func (s *VaultStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), recordAccessKeyUsageTimeout) + defer cancel() + if err := s.recordAccessKeyUsage(ctx, accessKeyID, service, region, when); err != nil { + debuglogger.Logf("failed to record Vault access key last-used metadata for %q: %v", accessKeyID, err) + } + }() + return nil +} + +func (s *VaultStore) recordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error { + resp, err := s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + resp, err = s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + return err + } + } + + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + continue + } + if !slices.ContainsFunc(user.AccessKeys, func(k types.AccessKeyEntry) bool { return k.AccessKeyId == accessKeyID }) { + continue + } + + _, err = s.withUserCAS(ctx, username, func(u *types.User) error { + for i, key := range u.AccessKeys { + if key.AccessKeyId == accessKeyID { + u.AccessKeys[i].LastUsedDate = when + u.AccessKeys[i].LastUsedService = service + u.AccessKeys[i].LastUsedRegion = region + return nil + } + } + return iamerr.NoSuchEntityAccessKey(accessKeyID) + }) + return err + } + + return iamerr.NoSuchEntityAccessKey(accessKeyID) +} + func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { user, err := s.GetUser(ctx, input.UserName) if err != nil { @@ -606,38 +863,34 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp } func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return err - } - - newTotal := len(input.PolicyDocument) - replaceAt := -1 - for i, p := range user.Policies.Inline { - if p.PolicyName == input.PolicyName { - replaceAt = i - continue + _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) } - newTotal += len(p.PolicyDocument) - } - if newTotal > MaxInlinePolicyBytesPerUser { - return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) - } - now := time.Now().UTC().Truncate(time.Second) - if replaceAt >= 0 { - user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument - user.Policies.Inline[replaceAt].UpdateDate = now - } else { - user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ - PolicyName: input.PolicyName, - PolicyDocument: input.PolicyDocument, - CreateDate: now, - UpdateDate: now, - }) - } - - _, err = s.replaceUser(ctx, *user) + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + return nil + }) return err } @@ -658,25 +911,20 @@ func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName str } func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error { - user, err := s.GetUser(ctx, userName) - if err != nil { - return err - } - - idx := -1 - for i, p := range user.Policies.Inline { - if p.PolicyName == policyName { - idx = i - break + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityUserPolicy(userName, policyName) - } - - user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) - - _, err = s.replaceUser(ctx, *user) + if idx == -1 { + return iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + return nil + }) return err } @@ -722,9 +970,10 @@ func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPolicie } // 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 +// checking for existence first. relPath is relative to secretStoragePath +// (e.g. "users/alice" or "sessions/AKIA..."). +func (s *VaultStore) deleteByPath(relPath string) error { + path := s.secretStoragePath + "/" + relPath _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) if err != nil { if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { @@ -739,44 +988,14 @@ func (s *VaultStore) deleteByPath(username string) error { } // rolesPath is the KV prefix under which roles are stored, kept distinct -// from secretStoragePath (which holds users) so listing one entity kind -// never has to filter out the other's keys. +// from usersPath so listing one entity kind never has to filter out the +// other's keys. func (s *VaultStore) rolesPath() string { return s.secretStoragePath + "/roles" } -// findRoleKey is findUserKey's counterpart for roles. -func (s *VaultStore) findRoleKey(name string) (string, bool, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return "", false, reauthErr - } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - return "", false, err - } - } - for _, key := range resp.Data.Keys { - if strings.EqualFold(key, name) { - return key, true, nil - } - } - return "", false, nil -} - func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { - if _, ok, err := s.findRoleKey(role.RoleName); err != nil { - return nil, err - } else if ok { - return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) - } + key := caseFoldKey(role.RoleName) role.EnsureRoleLastUsed() @@ -785,9 +1004,9 @@ func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role return nil, fmt.Errorf("serialize role: %w", err) } - path := s.rolesPath() + "/" + role.RoleName + path := s.rolesPath() + "/" + key req := schema.KvV2WriteRequest{ - Data: map[string]any{role.RoleName: roleMap}, + Data: map[string]any{key: roleMap}, Options: map[string]any{ "cas": 0, }, @@ -817,37 +1036,39 @@ func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role } func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { - canonical, ok, err := s.findRoleKey(roleName) - if err != nil { - return nil, err - } - if !ok { - return nil, iamerr.NoSuchEntityRole(roleName) - } + role, _, err := s.readRoleVersion(roleName) + return role, err +} - path := s.rolesPath() + "/" + canonical +// readRoleVersion is GetRole's counterpart to readUserVersion: it +// additionally returns the KV version the record was read at, so a +// mutation can write back with a matching CAS value instead of racing on a +// blind delete-then-recreate (see replaceRole). +func (s *VaultStore) readRoleVersion(roleName string) (*types.Role, int32, error) { + key := caseFoldKey(roleName) + path := s.rolesPath() + "/" + key resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityRole(roleName) + return nil, 0, iamerr.NoSuchEntityRole(roleName) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, reauthErr } resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityRole(roleName) + return nil, 0, iamerr.NoSuchEntityRole(roleName) } - return nil, err + return nil, 0, err } } - role, err := parseVaultRole(resp.Data.Data, canonical) + role, err := parseVaultRole(resp.Data.Data, key) if err != nil { - return nil, err + return nil, 0, err } - return cloneRole(role), nil + return cloneRole(role), kvVersion(resp.Data.Metadata), nil } func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) { @@ -921,60 +1142,68 @@ func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*List return out, nil } +// DeleteRole is DeleteUser's counterpart for roles - see its doc comment for +// why the dependency check (no inline policies) is confirmed via a same-data +// CAS write (replaceRole) immediately before the actual delete, instead of +// an unconditional delete straight after the check. func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error { - role, err := s.GetRole(ctx, roleName) - if err != nil { - return err + for range maxCASRetries { + role, version, err := s.readRoleVersion(roleName) + if err != nil { + return err + } + if len(role.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + + if _, err := s.replaceRole(ctx, *role, version); err != nil { + if errors.Is(err, errConcurrentModification) { + continue + } + return err + } + + return s.deleteRoleByPath(role.RoleName) } - if len(role.Policies.Inline) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) - } - return s.deleteRoleByPath(role.RoleName) + return iamerr.ConcurrentModification() } func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { - role, err := s.GetRole(ctx, input.RoleName) - if err != nil { - return nil, err - } - role.AssumeRolePolicyDocument = input.PolicyDocument - - return s.replaceRole(ctx, *role) + return s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error { + role.AssumeRolePolicyDocument = input.PolicyDocument + return nil + }) } func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { - role, err := s.GetRole(ctx, input.RoleName) - if err != nil { - return err - } - - newTotal := len(input.PolicyDocument) - replaceAt := -1 - for i, p := range role.Policies.Inline { - if p.PolicyName == input.PolicyName { - replaceAt = i - continue + _, err := s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error { + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) } - newTotal += len(p.PolicyDocument) - } - if newTotal > MaxInlinePolicyBytesPerRole { - return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) - } - now := time.Now().UTC().Truncate(time.Second) - if replaceAt >= 0 { - role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument - role.Policies.Inline[replaceAt].UpdateDate = now - } else { - role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ - PolicyName: input.PolicyName, - PolicyDocument: input.PolicyDocument, - CreateDate: now, - UpdateDate: now, - }) - } - - _, err = s.replaceRole(ctx, *role) + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + return nil + }) return err } @@ -995,25 +1224,20 @@ func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName str } func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error { - role, err := s.GetRole(ctx, roleName) - if err != nil { - return err - } - - idx := -1 - for i, p := range role.Policies.Inline { - if p.PolicyName == policyName { - idx = i - break + _, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error { + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityRolePolicy(roleName, policyName) - } - - role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) - - _, err = s.replaceRole(ctx, *role) + if idx == -1 { + return iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + return nil + }) return err } @@ -1058,19 +1282,66 @@ func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePolicie return out, nil } -// replaceRole overwrites the stored document for role.RoleName by deleting -// all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { - if err := s.deleteRoleByPath(role.RoleName); err != nil { - return nil, err +// replaceRole overwrites the stored document for role.RoleName using a +// version-checked (CAS) write tied to readVersion, instead of an +// unconditional delete-then-recreate — see replaceUser for the rationale. +func (s *VaultStore) replaceRole(ctx context.Context, role types.Role, readVersion int32) (*types.Role, error) { + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) } - return s.CreateRole(ctx, role) + + key := caseFoldKey(role.RoleName) + path := s.rolesPath() + "/" + key + req := schema.KvV2WriteRequest{ + Data: map[string]any{key: roleMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + return nil, err + } + } + return cloneRole(role), nil +} + +// withRoleCAS is withUserCAS's counterpart for roles. +func (s *VaultStore) withRoleCAS(ctx context.Context, roleName string, mutate func(*types.Role) error) (*types.Role, error) { + for range maxCASRetries { + role, version, err := s.readRoleVersion(roleName) + if err != nil { + return nil, err + } + if err := mutate(role); err != nil { + return nil, err + } + result, err := s.replaceRole(ctx, *role, version) + if err == nil { + return result, nil + } + if !errors.Is(err, errConcurrentModification) { + return nil, err + } + } + return nil, iamerr.ConcurrentModification() } // deleteRoleByPath permanently removes a role secret and all its versions // without checking for existence first. func (s *VaultStore) deleteRoleByPath(roleName string) error { - path := s.rolesPath() + "/" + roleName + path := s.rolesPath() + "/" + caseFoldKey(roleName) _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) if err != nil { if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { @@ -1200,9 +1471,19 @@ func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCPr } func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + provider, _, err := s.readOIDCProviderVersion(arn) + return provider, err +} + +// readOIDCProviderVersion is GetOIDCProvider's counterpart to +// readUserVersion/readRoleVersion: it additionally returns the KV version +// the record was read at, so a mutation can write back with a matching CAS +// value instead of racing on a blind delete-then-recreate (see +// replaceOIDCProvider). +func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, int32, error) { url, err := iamutil.ParseOIDCProviderArn(arn) if err != nil { - return nil, err + return nil, 0, err } segment := oidcProviderPathSegment(url) path := s.oidcProvidersPath() + "/" + segment @@ -1210,25 +1491,25 @@ func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDC resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, reauthErr } resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) } - return nil, err + return nil, 0, err } } provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) if err != nil { - return nil, err + return nil, 0, err } - return cloneOIDCProvider(provider), nil + return cloneOIDCProvider(provider), kvVersion(resp.Data.Metadata), nil } func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { @@ -1319,58 +1600,91 @@ func (s *VaultStore) deleteOIDCProviderByURL(url string) error { return nil } -// AddClientIDToOIDCProvider / RemoveClientIDFromOIDCProvider / -// UpdateOIDCProviderThumbprint use non-atomic get-then-replace, mirroring -// the existing consistency model of UpdateAssumeRolePolicy/PutRolePolicy's -// Vault implementations — this codebase has no CAS-protected -// read-modify-write for Vault mutations today, and this does not introduce -// one. - func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - if slices.Contains(provider.ClientIDList, clientID) { + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + if slices.Contains(provider.ClientIDList, clientID) { + return nil + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) return nil - } - if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { - return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) - } - provider.ClientIDList = append(provider.ClientIDList, clientID) - return s.replaceOIDCProvider(ctx, *provider) + }) } func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - idx := slices.Index(provider.ClientIDList, clientID) - if idx == -1 { + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return nil + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) return nil - } - provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) - return s.replaceOIDCProvider(ctx, *provider) + }) } func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - provider.ThumbprintList = thumbprints - return s.replaceOIDCProvider(ctx, *provider) + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + provider.ThumbprintList = thumbprints + return nil + }) } -// replaceOIDCProvider overwrites the stored document for provider.Url by -// deleting all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider) error { - if err := s.deleteOIDCProviderByURL(provider.Url); err != nil { - return err +// replaceOIDCProvider overwrites the stored document for provider.Url using +// a version-checked (CAS) write tied to readVersion, instead of an +// unconditional delete-then-recreate — see replaceUser for the rationale. +func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider, readVersion int32) error { + segment := oidcProviderPathSegment(provider.Url) + path := s.oidcProvidersPath() + "/" + segment + + providerMap, err := oidcProviderToVaultMap(provider) + if err != nil { + return fmt.Errorf("serialize oidc provider: %w", err) } - _, err := s.CreateOIDCProvider(ctx, provider) - return err + req := schema.KvV2WriteRequest{ + Data: map[string]any{segment: providerMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return errConcurrentModification + } + return err + } + } + return nil +} + +// withOIDCProviderCAS is withUserCAS's counterpart for OIDC providers. +func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, mutate func(*types.OIDCProvider) error) error { + for range maxCASRetries { + provider, version, err := s.readOIDCProviderVersion(arn) + if err != nil { + return err + } + if err := mutate(provider); err != nil { + return err + } + err = s.replaceOIDCProvider(ctx, *provider, version) + if err == nil { + return nil + } + if !errors.Is(err, errConcurrentModification) { + return err + } + } + return iamerr.ConcurrentModification() } var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine") @@ -1411,6 +1725,220 @@ func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProv return provider, nil } +// sessionsPath is the KV prefix under which AssumeRoleWithWebIdentity +// sessions are stored, kept distinct from secretStoragePath/rolesPath/ +// oidcProvidersPath. +func (s *VaultStore) sessionsPath() string { + return s.secretStoragePath + "/sessions" +} + +func (s *VaultStore) CreateSession(ctx context.Context, session types.Session) (*types.Session, error) { + // Bound how many concurrently-active sessions a single role can + // accumulate — without this, one valid federated token replayed against + // AssumeRoleWithWebIdentity indefinitely grows the number of KV paths + // and metadata records this backend has to carry for that role. + count, err := s.activeSessionCountForRole(ctx, session.RoleArn) + if err != nil { + return nil, err + } + if count >= MaxActiveSessionsPerRole { + return nil, iamerr.GetAPIError(iamerr.ErrThrottling) + } + + path := s.sessionsPath() + "/" + session.AccessKeyId + + // Pin the secret's own TTL to the session's expiration via Vault's + // native KV v2 delete_version_after metadata, so an expired session is + // eventually purged from storage by Vault itself even if GetSession is + // never called again for it (e.g. a session minted once and never + // reused) — GetSession's own expired-session delete only reclaims + // storage for sessions someone actually looks up again. + // + // This must happen *before* the version below is written: Vault + // computes a version's deletion_time from whatever delete_version_after + // is in effect at the moment that version is written, not retroactively + // — setting it afterward leaves an already-written version with no + // deletion_time at all (confirmed against a live Vault server: a + // version written before delete_version_after was set was never + // scheduled for deletion, while one written after was). Best-effort: a + // failure here still leaves a fully functional (if not self-cleaning) + // session, so it's logged rather than failing the create. + if err := s.setSessionTTL(path, session.Expiration); err != nil { + debuglogger.Logf("failed to set Vault session TTL metadata for access key %q: %v", session.AccessKeyId, err) + } + + sessionMap, err := sessionToVaultMap(session) + if err != nil { + return nil, fmt.Errorf("serialize session: %w", err) + } + req := schema.KvV2WriteRequest{ + Data: map[string]any{session.AccessKeyId: sessionMap}, + Options: map[string]any{"cas": 0}, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + return nil, err + } + } + + cloned := session + return &cloned, nil +} + +// activeSessionCountForRole counts this backend's currently-active sessions +// belonging to roleArn, so CreateSession can enforce +// MaxActiveSessionsPerRole. GetSession is reused to read each candidate +// entry: it already purges an expired-but-not-yet-Vault-reaped session on +// read, so an expired session is neither counted nor left to inflate a +// future count. +func (s *VaultStore) activeSessionCountForRole(ctx context.Context, roleArn string) (int, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return 0, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return 0, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return 0, nil + } + return 0, err + } + } + + count := 0 + for _, key := range resp.Data.Keys { + session, err := s.GetSession(ctx, key) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + continue + } + return 0, err + } + if session.RoleArn == roleArn { + count++ + } + } + return count, nil +} + +// setSessionTTL sets path's KV v2 delete_version_after metadata to the +// duration remaining until expiration, so Vault purges the version itself +// once it's expired. +func (s *VaultStore) setSessionTTL(path string, expiration time.Time) error { + ttl := time.Until(expiration) + if ttl <= 0 { + ttl = time.Second + } + + req := schema.KvV2WriteMetadataRequest{DeleteVersionAfter: fmt.Sprintf("%.0fs", ttl.Seconds())} + _, err := s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...) + } + return err +} + +func (s *VaultStore) GetSession(_ context.Context, accessKeyID string) (*types.Session, error) { + path := s.sessionsPath() + "/" + accessKeyID + + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + // Either this access key never existed, or Vault's own + // delete_version_after TTL (see setSessionTTL) already + // soft-deleted the version — confirmed live: Vault answers a + // read for a soft-deleted-but-not-yet-destroyed version with + // 404, not 200-with-null-data. Either way, best-effort purge + // the lingering metadata record now, since Vault doesn't + // appear to reclaim it on its own once merely soft-deleted. + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound + } + 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) { + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound + } + return nil, err + } + } + + session, err := parseVaultSession(resp.Data.Data, accessKeyID) + if err == nil && session.Expiration.After(time.Now().UTC()) { + cloned := session + return &cloned, nil + } + + // Readable but our own Expiration field says it's past due anyway + // (should be rare/racy, since setSessionTTL pins Vault's own TTL to + // this same value) — purge now rather than waiting on Vault. + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound +} + +// purgeSession permanently deletes accessKeyID's session metadata and +// version record. Best-effort: a failure just leaves the (already +// not-found-to-the-caller) entry lingering until some later call retries +// the purge or Vault's own cleanup eventually catches it. +func (s *VaultStore) purgeSession(accessKeyID string) { + if err := s.deleteByPath("sessions/" + accessKeyID); err != nil { + debuglogger.Logf("failed to delete expired Vault session for access key %q: %v", accessKeyID, err) + } +} + +var errInvalidVaultSession = errors.New("invalid session entry in vault secrets engine") + +func sessionToVaultMap(session types.Session) (map[string]any, error) { + b, err := json.Marshal(session) + 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 +} + +// parseVaultSession reconstructs a Session from the raw map[string]any +// vault returns. The outer key is the AccessKeyId. +func parseVaultSession(data map[string]any, accessKeyID string) (types.Session, error) { + raw, ok := data[accessKeyID] + if !ok { + return types.Session{}, errInvalidVaultSession + } + sessionMap, ok := raw.(map[string]any) + if !ok { + return types.Session{}, errInvalidVaultSession + } + b, err := json.Marshal(sessionMap) + if err != nil { + return types.Session{}, fmt.Errorf("re-marshal vault session: %w", err) + } + var session types.Session + if err := json.Unmarshal(b, &session); err != nil { + return types.Session{}, fmt.Errorf("unmarshal vault session: %w", err) + } + return session, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/identity.go b/iamapi/types/identity.go new file mode 100644 index 00000000..f28314ec --- /dev/null +++ b/iamapi/types/identity.go @@ -0,0 +1,48 @@ +// 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 + +// Identity is the caller identity the auth middleware resolves for a +// request, shared across the auth middleware, the policy middleware, and +// controllers (GetCallerIdentity) so the access key only ever needs to be +// resolved once per request. +// +// Exactly one of IsRoot, User, or Session is set: +// - IsRoot: the configured root credential. Bypasses policy evaluation +// entirely, matching real AWS's root user. +// - User: a long-term (AKIA…) IAM user credential. IdentityPolicies holds +// that user's own inline policy documents. +// - Session: a temporary (ASIA…) credential minted by +// AssumeRoleWithWebIdentity. Role is the assumed role; IdentityPolicies +// holds the role's inline policy documents, and SessionPolicy — if +// non-empty — is an additional filter that can only narrow, never +// widen, what the role otherwise allows (Effective permissions = Role +// identity-based permissions ∩ Session policy permissions). +type Identity struct { + IsRoot bool + User *User + Role *Role + Session *Session + + // IdentityPolicies are the inline policies to evaluate for + // authorization: the User's own policies, or the assumed Role's + // policies for a Session. Unset (nil) when IsRoot. + IdentityPolicies []PolicyEntry + + // SessionPolicy is the session's own inline policy document (the + // AssumeRoleWithWebIdentity Policy parameter), or "" if none was + // supplied. Only ever set alongside Session. + SessionPolicy string +} diff --git a/iamapi/types/sts.go b/iamapi/types/sts.go new file mode 100644 index 00000000..2ee4f020 --- /dev/null +++ b/iamapi/types/sts.go @@ -0,0 +1,98 @@ +// 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" +) + +// Session is the storage-layer representation of a temporary credential set +// minted by AssumeRoleWithWebIdentity. It is never marshaled to XML +// directly — GetCallerIdentity and (in a later change) S3 request +// authentication read it back by AccessKeyId to resolve the calling +// identity. +type Session struct { + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + RoleArn string `json:"roleArn"` + RoleName string `json:"roleName"` + RoleID string `json:"roleId"` + RoleSessionName string `json:"roleSessionName"` + Provider string `json:"provider"` + Audience string `json:"audience"` + Subject string `json:"subject"` + CreateDate time.Time `json:"createDate"` + Expiration time.Time `json:"expiration"` + // Policy is the optional inline session policy document supplied via + // AssumeRoleWithWebIdentity's Policy parameter, or "" if none was + // supplied. It can only narrow, never widen, the assumed role's own + // permissions. + Policy string `json:"policy,omitempty"` +} + +// Credentials is the temporary security credential set returned by +// AssumeRoleWithWebIdentity. +type Credentials struct { + AccessKeyId string + SecretAccessKey string + SessionToken string + Expiration time.Time +} + +// AssumedRoleUser identifies the principal produced by assuming a role. +type AssumedRoleUser struct { + AssumedRoleId string + Arn string +} + +type AssumeRoleWithWebIdentityResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithWebIdentityResponse"` + Result AssumeRoleWithWebIdentityResult `xml:"AssumeRoleWithWebIdentityResult"` + ResponseMetadata ResponseMetadata +} + +func (r *AssumeRoleWithWebIdentityResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type AssumeRoleWithWebIdentityResult struct { + Audience string `xml:",omitempty"` + AssumedRoleUser AssumedRoleUser + Provider string + Credentials Credentials + SubjectFromWebIdentityToken string + // PackedPolicySize is a percentage indicating how close the request's + // session policy came to its size quota; nil (and therefore omitted, + // matching AWS) when no session Policy parameter was supplied. + PackedPolicySize *int64 `xml:",omitempty"` +} + +type GetCallerIdentityResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetCallerIdentityResponse"` + Result GetCallerIdentityResult `xml:"GetCallerIdentityResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetCallerIdentityResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetCallerIdentityResult struct { + Arn string + UserId string + Account string +} diff --git a/internal/httpctx/context_keys.go b/internal/httpctx/context_keys.go index 4c7fba7f..5d9fa59e 100644 --- a/internal/httpctx/context_keys.go +++ b/internal/httpctx/context_keys.go @@ -37,6 +37,7 @@ const ( ContextKeyRequestID ContextKey = "request-id" ContextKeyHostID ContextKey = "host-id" ContextKeyWebsiteConfig ContextKey = "website-config" + ContextKeyCallerIdentity ContextKey = "iam-caller-identity" ) func (ck ContextKey) Set(ctx fiber.Ctx, val any) { diff --git a/internal/sigv4auth/auth.go b/internal/sigv4auth/auth.go index 73c54790..92dd4120 100644 --- a/internal/sigv4auth/auth.go +++ b/internal/sigv4auth/auth.go @@ -27,9 +27,14 @@ const ( Terminal = "aws4_request" ServiceS3 = "s3" ServiceIAM = "iam" + ServiceSTS = "sts" ISO8601Format = "20060102T150405Z" YYYYMMDD = "20060102" + + // HeaderSecurityToken is the header a temporary credential's + // SessionToken is presented in, matching AWS's X-Amz-Security-Token. + HeaderSecurityToken = "X-Amz-Security-Token" ) type ParseErrorKind string diff --git a/internal/sigv4auth/compare.go b/internal/sigv4auth/compare.go new file mode 100644 index 00000000..3015621e --- /dev/null +++ b/internal/sigv4auth/compare.go @@ -0,0 +1,33 @@ +// 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/subtle" + +// SecureCompare reports whether a and b are equal, comparing in time +// independent of their shared-prefix length. Used for authentication +// secrets — a computed SigV4 signature against the one the caller supplied, +// or a session token against its stored value — where an ordinary == +// comparison's early-exit on the first differing byte could, in principle, +// leak prefix-match information to a sufficiently patient and precise +// remote timing attacker. A length mismatch is reported as unequal without +// running the constant-time comparison at all: subtle.ConstantTimeCompare +// requires equal-length inputs, and the length of a fixed-format +// signature/token is not itself secret. +func SecureCompare(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/sigv4auth/compare_test.go b/internal/sigv4auth/compare_test.go new file mode 100644 index 00000000..8af89163 --- /dev/null +++ b/internal/sigv4auth/compare_test.go @@ -0,0 +1,39 @@ +// 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 "testing" + +func TestSecureCompare(t *testing.T) { + tests := []struct { + name string + a, b string + want bool + }{ + {"equal", "abc123", "abc123", true}, + {"different content, same length", "abc123", "abc124", false}, + {"different length", "abc123", "abc1234", false}, + {"empty vs empty", "", "", true}, + {"empty vs non-empty", "", "a", false}, + {"shares a long common prefix but differs at the end", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay", false}, + {"differs only in the first byte", "xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "yaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SecureCompare(tt.a, tt.b); got != tt.want { + t.Errorf("SecureCompare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/internal/sigv4auth/query.go b/internal/sigv4auth/query.go index 5fd2da5c..6ad04c6c 100644 --- a/internal/sigv4auth/query.go +++ b/internal/sigv4auth/query.go @@ -278,7 +278,11 @@ func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash strin req, payloadHash, service, auth.Region, tdate, signedHdrs, func(options *v4.SignerOptions) { options.DisableURIPathEscaping = opts.DisableURIPathEscaping - if debuglogger.IsDebugEnabled() { + // See the identical comment in verify.go's CheckSignature: this + // logger dumps a complete, replayable signed URL (including + // X-Amz-Signature and any session token) unredacted, so it may + // only run at LevelUnsafe. + if debuglogger.IsUnsafeEnabled() { options.LogSigning = true options.Logger = logging.NewStandardLogger(os.Stderr) } @@ -293,7 +297,7 @@ func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash strin } signature := urlParts.Query().Get(QuerySignature) - if signature != auth.Signature { + if !SecureCompare(signature, auth.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, StringToSign: signMeta.StringToSign, diff --git a/internal/sigv4auth/verify.go b/internal/sigv4auth/verify.go index 08f6c790..ca02567d 100644 --- a/internal/sigv4auth/verify.go +++ b/internal/sigv4auth/verify.go @@ -88,7 +88,12 @@ func CheckSignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, td req, payloadHash, service, auth.Region, tdate, signedHdrs, func(options *v4.SignerOptions) { options.DisableURIPathEscaping = opts.DisableURIPathEscaping - if debuglogger.IsDebugEnabled() { + // The signer's diagnostic logger prints the canonical request, + // string-to-sign, and (for presigned requests) the complete + // signed URL verbatim, bypassing the redaction layer entirely. + // That's replayable signature/session-token material, so only + // enable it at LevelUnsafe, never at plain debug. + if debuglogger.IsUnsafeEnabled() { options.LogSigning = true options.Logger = logging.NewStandardLogger(os.Stderr) } @@ -102,7 +107,7 @@ func CheckSignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, td return nil, err } - if auth.Signature != genAuth.Signature { + if !SecureCompare(auth.Signature, genAuth.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, StringToSign: signMeta.StringToSign, diff --git a/s3api/admin-server.go b/s3api/admin-server.go index 09460dfd..c9f40048 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -75,6 +75,9 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | adm | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } // initialize requestId middleware diff --git a/s3api/server.go b/s3api/server.go index a87e13a3..c0d542ee 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -129,6 +129,9 @@ func New( if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 8f380187..4c61d94e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1450,6 +1450,109 @@ func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) } +func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { + ts.Run(IAMAssumeRoleWithWebIdentity_missing_role_arn) + ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_too_short) + ts.Run(IAMAssumeRoleWithWebIdentity_malformed_duration) + ts.Run(IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action) + ts.Run(IAMAssumeRoleWithWebIdentity_malformed_token) + ts.Run(IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max) + ts.Run(IAMAssumeRoleWithWebIdentity_nonexistent_role) + ts.Run(IAMAssumeRoleWithWebIdentity_no_matching_principal) + ts.Run(IAMAssumeRoleWithWebIdentity_no_issuer_match) + ts.Run(IAMAssumeRoleWithWebIdentity_condition_failed) + ts.Run(IAMAssumeRoleWithWebIdentity_explicit_deny) + ts.Run(IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list) + ts.Run(IAMAssumeRoleWithWebIdentity_empty_client_id_list) + ts.Run(IAMAssumeRoleWithWebIdentity_idp_communication_error) + ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_policy_arns_rejected) + ts.Run(IAMAssumeRoleWithWebIdentity_provider_id_rejected) + ts.Run(IAMAssumeRoleWithWebIdentity_session_policy_too_large) + ts.Run(IAMAssumeRoleWithWebIdentity_session_policy_invalid) + ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_matches) + ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch) +} + +func TestIAMGetCallerIdentity(ts *TestState) { + ts.Run(IAMGetCallerIdentity_root_success) + ts.Run(IAMGetCallerIdentity_user_success) + ts.Run(IAMGetCallerIdentity_unknown_access_key) + ts.Run(IAMGetCallerIdentity_no_auth) + ts.Run(IAMGetCallerIdentity_wrong_version_is_invalid_action) + ts.Run(IAMGetCallerIdentity_incorrect_service_scope) +} + +func TestIAMAccessControl(ts *TestState) { + ts.Run(IAMAccessControl_ImplicitDenyNoMatchingPolicy) + ts.Run(IAMAccessControl_AllowGrantsMatchingRequest) + ts.Run(IAMAccessControl_NonMatchingStatementDoesNotGrant) + ts.Run(IAMAccessControl_ExplicitDenyOverridesAllow) + ts.Run(IAMAccessControl_MultipleStatementsEvaluatedIndependently) + ts.Run(IAMAccessControl_MultipleInlinePoliciesCombinedAllow) + ts.Run(IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins) + ts.Run(IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies) + ts.Run(IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow) + ts.Run(IAMAccessControl_ActionMatchingVariants) + ts.Run(IAMAccessControl_ActionAllowOneDenyAnotherByOmission) + ts.Run(IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow) + ts.Run(IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded) + ts.Run(IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded) + ts.Run(IAMAccessControl_ResourceMatchingVariants) + ts.Run(IAMAccessControl_ResourceOneAllowedOneDeniedSameAction) + ts.Run(IAMAccessControl_ResourceWildcardRequiredForListAction) + ts.Run(IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow) + ts.Run(IAMAccessControl_NotResourceExcludesTarget) + ts.Run(IAMAccessControl_NotResourceMultipleExcludedResources) + ts.Run(IAMAccessControl_NotResourceWildcardExclusion) + ts.Run(IAMAccessControl_ConditionStringOperators) + ts.Run(IAMAccessControl_ConditionStringMultipleExpectedValuesOR) + ts.Run(IAMAccessControl_ConditionArnOperators) + ts.Run(IAMAccessControl_ConditionIpAddressRealSourceIp) + ts.Run(IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow) + ts.Run(IAMAccessControl_ConditionMultipleContextKeysANDed) + ts.Run(IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply) + ts.Run(IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins) + ts.Run(IAMAccessControl_ConditionOneFailedConditionVoidsStatement) + ts.Run(IAMAccessControl_ConditionNullPrincipalTag) + ts.Run(IAMAccessControl_ConditionIfExistsPrincipalTag) + ts.Run(IAMAccessControl_ConditionResourceTagOnTarget) + ts.Run(IAMAccessControl_ConditionRequestTagOnCreateUser) + ts.Run(IAMAccessControl_ConditionCurrentTimeBroadWindow) + ts.Run(IAMAccessControl_ConditionNumericOperators) + ts.Run(IAMAccessControl_ConditionDateOperators) + ts.Run(IAMAccessControl_ConditionBoolOperator) + ts.Run(IAMAccessControl_ConditionNullOperatorClaim) + ts.Run(IAMAccessControl_ConditionBinaryEqualsOperator) + ts.Run(IAMAccessControl_ConditionForAnyValueOperator) + ts.Run(IAMAccessControl_ConditionForAllValuesOperator) + ts.Run(IAMAccessControl_ConditionIfExistsTrustClaim) + ts.Run(IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust) + ts.Run(IAMAccessControl_TrustPolicyFederatedExactMatchAllowed) + ts.Run(IAMAccessControl_TrustPolicyFederatedWrongProviderDenied) + ts.Run(IAMAccessControl_TrustPolicyFederatedArrayMatchesAny) + ts.Run(IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored) + ts.Run(IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed) + ts.Run(IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied) + ts.Run(IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed) + ts.Run(IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied) + ts.Run(IAMAccessControl_TrustPolicyAudienceCorrectAllowed) + ts.Run(IAMAccessControl_TrustPolicyAudienceIncorrectDenied) + ts.Run(IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed) + ts.Run(IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch) + ts.Run(IAMAccessControl_TrustPolicyExplicitDenyStatement) + ts.Run(IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants) + ts.Run(IAMAccessControl_TrustPolicyMissingRequiredClaimDenied) + ts.Run(IAMAccessControl_UserInlinePolicyWorkflow) + ts.Run(IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath) + ts.Run(IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision) + ts.Run(IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy) + ts.Run(IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer) + ts.Run(IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1483,6 +1586,9 @@ func TestIAM(ts *TestState) { TestIAMAddClientIDToOpenIDConnectProvider(ts) TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) TestIAMUpdateOpenIDConnectProviderThumbprint(ts) + TestIAMAssumeRoleWithWebIdentity(ts) + TestIAMGetCallerIdentity(ts) + TestIAMAccessControl(ts) } func TestAccessControl(ts *TestState) { @@ -1774,1114 +1880,1208 @@ type IntTests map[string]IntTest func GetIntTests() IntTests { return IntTests{ - "Authentication_invalid_auth_header": Authentication_invalid_auth_header, - "Authentication_unsupported_signature_version": Authentication_unsupported_signature_version, - "Authentication_missing_components": Authentication_missing_components, - "Authentication_malformed_component": Authentication_malformed_component, - "Authentication_missing_credentials": Authentication_missing_credentials, - "Authentication_missing_signedheaders": Authentication_missing_signedheaders, - "Authentication_missing_signature": Authentication_missing_signature, - "Authentication_malformed_credential": Authentication_malformed_credential, - "Authentication_credentials_invalid_terminal": Authentication_credentials_invalid_terminal, - "Authentication_credentials_incorrect_service": Authentication_credentials_incorrect_service, - "Authentication_credentials_incorrect_region": Authentication_credentials_incorrect_region, - "Authentication_credentials_invalid_date": Authentication_credentials_invalid_date, - "Authentication_credentials_future_date": Authentication_credentials_future_date, - "Authentication_credentials_past_date": Authentication_credentials_past_date, - "Authentication_credentials_non_existing_access_key": Authentication_credentials_non_existing_access_key, - "Authentication_missing_date_header": Authentication_missing_date_header, - "Authentication_invalid_date_header": Authentication_invalid_date_header, - "Authentication_date_mismatch": Authentication_date_mismatch, - "Authentication_incorrect_payload_hash": Authentication_incorrect_payload_hash, - "Authentication_invalid_sha256_payload_hash": Authentication_invalid_sha256_payload_hash, - "Authentication_unsigned_required_header": Authentication_unsigned_required_header, - "Authentication_unsigned_non_required_header": Authentication_unsigned_non_required_header, - "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_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, - "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_has_access_keys": IAMDeleteUser_has_access_keys, - "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, - "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, - "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, - "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, - "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, - "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, - "IAMCreateAccessKey_success": IAMCreateAccessKey_success, - "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, - "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, - "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, - "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, - "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, - "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, - "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, - "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, - "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, - "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, - "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, - "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, - "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, - "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, - "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, - "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, - "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, - "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, - "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, - "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, - "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, - "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, - "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, - "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, - "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, - "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, - "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, - "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, - "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, - "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, - "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, - "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, - "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, - "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, - "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, - "IAMListAccessKeys_success": IAMListAccessKeys_success, - "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, - "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, - "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, - "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, - "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, - "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, - "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, - "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, - "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, - "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, - "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, - "IAMPutUserPolicy_success": IAMPutUserPolicy_success, - "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, - "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, - "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, - "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, - "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, - "IAMGetUserPolicy_success": IAMGetUserPolicy_success, - "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, - "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, - "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, - "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, - "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, - "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, - "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, - "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, - "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, - "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, - "IAMListUserPolicies_success": IAMListUserPolicies_success, - "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, - "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, - "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, - "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, - "IAMCreateRole_already_exists": IAMCreateRole_already_exists, - "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, - "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, - "IAMCreateRole_long_path": IAMCreateRole_long_path, - "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, - "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, - "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, - "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, - "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, - "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, - "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, - "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, - "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, - "IAMCreateRole_success": IAMCreateRole_success, - "IAMCreateRole_defaults": IAMCreateRole_defaults, - "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, - "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, - "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, - "IAMGetRole_long_role_name": IAMGetRole_long_role_name, - "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, - "IAMGetRole_success": IAMGetRole_success, - "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, - "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, - "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, - "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, - "IAMListRoles_empty_result": IAMListRoles_empty_result, - "IAMListRoles_success": IAMListRoles_success, - "IAMListRoles_path_prefix": IAMListRoles_path_prefix, - "IAMListRoles_pagination": IAMListRoles_pagination, - "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, - "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, - "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, - "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, - "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, - "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, - "IAMDeleteRole_success": IAMDeleteRole_success, - "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, - "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, - "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, - "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, - "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, - "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, - "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, - "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, - "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, - "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, - "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, - "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, - "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, - "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, - "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, - "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, - "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, - "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, - "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, - "IAMPutRolePolicy_success": IAMPutRolePolicy_success, - "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, - "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, - "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, - "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, - "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, - "IAMGetRolePolicy_success": IAMGetRolePolicy_success, - "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, - "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, - "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, - "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, - "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, - "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, - "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, - "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, - "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, - "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, - "IAMListRolePolicies_success": IAMListRolePolicies_success, - "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, - "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, - "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, - "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, - "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, - "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, - "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, - "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, - "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, - "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, - "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, - "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, - "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, - "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, - "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, - "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, - "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, - "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, - "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, - "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, - "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, - "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, - "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, - "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, - "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, - "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, - "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, - "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, - "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, - "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, - "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, - "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, - "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, - "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, - "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, - "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, - "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, - "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, - "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, - "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, - "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, - "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, - "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, - "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, - "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, - "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, - "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, - "PresignedAuth_missing_signature_query_param": PresignedAuth_missing_signature_query_param, - "PresignedAuth_missing_credentials_query_param": PresignedAuth_missing_credentials_query_param, - "PresignedAuth_malformed_creds_invalid_parts": PresignedAuth_malformed_creds_invalid_parts, - "PresignedAuth_creds_invalid_terminal": PresignedAuth_creds_invalid_terminal, - "PresignedAuth_creds_incorrect_service": PresignedAuth_creds_incorrect_service, - "PresignedAuth_creds_incorrect_region": PresignedAuth_creds_incorrect_region, - "PresignedAuth_creds_invalid_date": PresignedAuth_creds_invalid_date, - "PresignedAuth_missing_date_query": PresignedAuth_missing_date_query, - "PresignedAuth_dates_mismatch": PresignedAuth_dates_mismatch, - "PresignedAuth_non_existing_access_key_id": PresignedAuth_non_existing_access_key_id, - "PresignedAuth_missing_signed_headers_query_param": PresignedAuth_missing_signed_headers_query_param, - "PresignedAuth_unsigned_required_header": PresignedAuth_unsigned_required_header, - "PresignedAuth_unsigned_non_required_header": PresignedAuth_unsigned_non_required_header, - "PresignedAuth_missing_expiration_query_param": PresignedAuth_missing_expiration_query_param, - "PresignedAuth_invalid_expiration_query_param": PresignedAuth_invalid_expiration_query_param, - "PresignedAuth_negative_expiration_query_param": PresignedAuth_negative_expiration_query_param, - "PresignedAuth_exceeding_expiration_query_param": PresignedAuth_exceeding_expiration_query_param, - "PresignedAuth_expired_request": PresignedAuth_expired_request, - "PresignedAuth_incorrect_secret_key": PresignedAuth_incorrect_secret_key, - "PresignedAuth_sigv2_not_supported": PresignedAuth_sigv2_not_supported, - "PresignedAuth_PutObject_success": PresignedAuth_PutObject_success, - "PutObject_missing_object_lock_retention_config": PutObject_missing_object_lock_retention_config, - "PutObject_name_too_long": PutObject_name_too_long, - "PutObject_with_object_lock": PutObject_with_object_lock, - "PutObject_missing_bucket_lock": PutObject_missing_bucket_lock, - "PutObject_invalid_legal_hold": PutObject_invalid_legal_hold, - "PutObject_invalid_object_lock_mode": PutObject_invalid_object_lock_mode, - "PutObject_past_retain_until_date": PutObject_past_retain_until_date, - "PutObject_invalid_retain_until_date": PutObject_invalid_retain_until_date, - "PutObject_conditional_writes": PutObject_conditional_writes, - "PutObject_should_combine_metadata": PutObject_should_combine_metadata, - "PutObject_md5": PutObject_md5, - "PutObject_long_metadata": PutObject_long_metadata, - "PutObject_with_metadata": PutObject_with_metadata, - "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, - "PutObject_invalid_credentials": PutObject_invalid_credentials, - "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, - "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, - "PutObject_invalid_checksum_header": PutObject_invalid_checksum_header, - "PutObject_incorrect_checksums": PutObject_incorrect_checksums, - "PutObject_default_checksum": PutObject_default_checksum, - "PutObject_data_integrity_etag": PutObject_data_integrity_etag, - "PutObject_dir_object_data_integrity_etag": PutObject_dir_object_data_integrity_etag, - "PutObject_dir_object_default_checksum": PutObject_dir_object_default_checksum, - "PutObject_checksums_success": PutObject_checksums_success, - "PutObject_dir_object_checksums_success": PutObject_dir_object_checksums_success, - "PresignedAuth_Put_GetObject_with_data": PresignedAuth_Put_GetObject_with_data, - "PresignedAuth_Put_GetObject_with_UTF8_chars": PresignedAuth_Put_GetObject_with_UTF8_chars, - "PresignedAuth_UploadPart": PresignedAuth_UploadPart, - "CreateBucket_invalid_bucket_name": CreateBucket_invalid_bucket_name, - "CreateBucket_existing_bucket": CreateBucket_existing_bucket, - "CreateBucket_owned_by_you": CreateBucket_owned_by_you, - "CreateBucket_invalid_ownership": CreateBucket_invalid_ownership, - "CreateBucket_ownership_with_acl": CreateBucket_ownership_with_acl, - "CreateBucket_as_user": CreateBucket_as_user, - "CreateBucket_success": CreateBucket_success, - "CreateBucket_default_acl": CreateBucket_default_acl, - "CreateBucket_non_default_acl": CreateBucket_non_default_acl, - "CreateBucket_private_canned_acl": CreateBucket_private_canned_acl, - "CreateBucket_private_canned_acl_bucket_owner_enforced_ownership": CreateBucket_private_canned_acl_bucket_owner_enforced_ownership, - "CreateBucket_default_object_lock": CreateBucket_default_object_lock, - "CreateBucket_invalid_location_constraint": CreateBucket_invalid_location_constraint, - "CreateBucket_long_tags": CreateBucket_long_tags, - "CreateBucket_invalid_tags": CreateBucket_invalid_tags, - "CreateBucket_duplicate_keys": CreateBucket_duplicate_keys, - "CreateBucket_tag_count_limit": CreateBucket_tag_count_limit, - "CreateBucket_invalid_canned_acl": CreateBucket_invalid_canned_acl, - "HeadBucket_non_existing_bucket": HeadBucket_non_existing_bucket, - "HeadBucket_success": HeadBucket_success, - "ListBuckets_as_user": ListBuckets_as_user, - "ListBuckets_as_admin": ListBuckets_as_admin, - "ListBuckets_with_prefix": ListBuckets_with_prefix, - "ListBuckets_invalid_max_buckets": ListBuckets_invalid_max_buckets, - "ListBuckets_truncated": ListBuckets_truncated, - "ListBuckets_success": ListBuckets_success, - "ListBuckets_empty_success": ListBuckets_empty_success, - "DeleteBucket_non_existing_bucket": DeleteBucket_non_existing_bucket, - "DeleteBucket_non_empty_bucket": DeleteBucket_non_empty_bucket, - "DeleteBucket_incorrect_expected_bucket_owner": DeleteBucket_incorrect_expected_bucket_owner, - "DeleteBucket_success_status_code": DeleteBucket_success_status_code, - "PutBucketOwnershipControls_non_existing_bucket": PutBucketOwnershipControls_non_existing_bucket, - "PutBucketOwnershipControls_multiple_rules": PutBucketOwnershipControls_multiple_rules, - "PutBucketOwnershipControls_invalid_ownership": PutBucketOwnershipControls_invalid_ownership, - "PutBucketOwnershipControls_empty_rules": PutBucketOwnershipControls_empty_rules, - "PutBucketOwnershipControls_success": PutBucketOwnershipControls_success, - "GetBucketOwnershipControls_non_existing_bucket": GetBucketOwnershipControls_non_existing_bucket, - "GetBucketOwnershipControls_default_ownership": GetBucketOwnershipControls_default_ownership, - "GetBucketOwnershipControls_success": GetBucketOwnershipControls_success, - "DeleteBucketOwnershipControls_non_existing_bucket": DeleteBucketOwnershipControls_non_existing_bucket, - "DeleteBucketOwnershipControls_success": DeleteBucketOwnershipControls_success, - "PutBucketTagging_non_existing_bucket": PutBucketTagging_non_existing_bucket, - "PutBucketTagging_long_tags": PutBucketTagging_long_tags, - "PutBucketTagging_invalid_tags": PutBucketTagging_invalid_tags, - "PutBucketTagging_duplicate_keys": PutBucketTagging_duplicate_keys, - "PutBucketTagging_tag_count_limit": PutBucketTagging_tag_count_limit, - "PutBucketTagging_success": PutBucketTagging_success, - "PutBucketTagging_success_status": PutBucketTagging_success_status, - "GetBucketTagging_non_existing_bucket": GetBucketTagging_non_existing_bucket, - "GetBucketTagging_unset_tags": GetBucketTagging_unset_tags, - "GetBucketTagging_success": GetBucketTagging_success, - "DeleteBucketTagging_non_existing_object": DeleteBucketTagging_non_existing_object, - "DeleteBucketTagging_success_status": DeleteBucketTagging_success_status, - "DeleteBucketTagging_success": DeleteBucketTagging_success, - "GetBucketLocation_success": GetBucketLocation_success, - "GetBucketLocation_non_exist": GetBucketLocation_non_exist, - "GetBucketLocation_no_access": GetBucketLocation_no_access, - "PutObject_non_existing_bucket": PutObject_non_existing_bucket, - "PutObject_special_chars": PutObject_special_chars, - "PutObject_tagging": PutObject_tagging, - "PutObject_success": PutObject_success, - "PutObject_default_content_type": PutObject_default_content_type, - "PutObject_invalid_object_names": PutObject_invalid_object_names, - "PutObject_object_acl_not_supported": PutObject_object_acl_not_supported, - "PutObject_false_negative_object_names": PutObject_false_negative_object_names, - "PutObject_racey_success": PutObject_racey_success, - "HeadObject_non_existing_object": HeadObject_non_existing_object, - "HeadObject_invalid_part_number": HeadObject_invalid_part_number, - "HeadObject_directory_object_noslash": HeadObject_directory_object_noslash, - "HeadObject_non_existing_dir_object": HeadObject_non_existing_dir_object, - "HeadObject_incidental_dir_object": HeadObject_incidental_dir_object, - "HeadObject_name_too_long": HeadObject_name_too_long, - "HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir, - "HeadObject_with_range": HeadObject_with_range, - "HeadObject_by_range_resp_status": HeadObject_by_range_resp_status, - "HeadObject_zero_len_with_range": HeadObject_zero_len_with_range, - "HeadObject_dir_with_range": HeadObject_dir_with_range, - "HeadObject_conditional_reads": HeadObject_conditional_reads, - "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, - "HeadObject_checksums": HeadObject_checksums, - "HeadObject_ranged_with_checksum_mode": HeadObject_ranged_with_checksum_mode, - "HeadObject_success": HeadObject_success, - "HeadObject_overrides_success": HeadObject_overrides_success, - "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, - "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, - "HeadObject_range_and_part_number": HeadObject_range_and_part_number, - "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, - "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, - "HeadObject_mp_part_number_resp_status": HeadObject_mp_part_number_resp_status, - "HeadObject_non_mp_part_number_1_success": HeadObject_non_mp_part_number_1_success, - "HeadObject_empty_object_part_number_1": HeadObject_empty_object_part_number_1, - "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, - "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, - "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs, - "GetObjectAttributes_invalid_parent": GetObjectAttributes_invalid_parent, - "GetObjectAttributes_invalid_single_attribute": GetObjectAttributes_invalid_single_attribute, - "GetObjectAttributes_empty_attrs": GetObjectAttributes_empty_attrs, - "GetObjectAttributes_existing_object": GetObjectAttributes_existing_object, - "GetObjectAttributes_checksums": GetObjectAttributes_checksums, - "GetObject_non_existing_key": GetObject_non_existing_key, - "GetObject_directory_object_noslash": GetObject_directory_object_noslash, - "GetObject_with_range": GetObject_with_range, - "GetObject_zero_len_with_range": GetObject_zero_len_with_range, - "GetObject_dir_with_range": GetObject_dir_with_range, - "GetObject_invalid_parent": GetObject_invalid_parent, - "GetObject_large_object": GetObject_large_object, - "GetObject_conditional_reads": GetObject_conditional_reads, - "GetObject_not_enabled_checksum_mode": GetObject_not_enabled_checksum_mode, - "GetObject_checksums": GetObject_checksums, - "GetObject_dir_object_checksum": GetObject_dir_object_checksum, - "GetObject_ranged_with_checksum_mode": GetObject_ranged_with_checksum_mode, - "GetObject_success": GetObject_success, - "GetObject_directory_success": GetObject_directory_success, - "GetObject_by_range_resp_status": GetObject_by_range_resp_status, - "GetObject_non_existing_dir_object": GetObject_non_existing_dir_object, - "GetObject_incidental_dir_object": GetObject_incidental_dir_object, - "GetObject_overrides_success": GetObject_overrides_success, - "GetObject_overrides_presign_success": GetObject_overrides_presign_success, - "GetObject_overrides_fail_public": GetObject_overrides_fail_public, - "GetObject_invalid_part_number": GetObject_invalid_part_number, - "GetObject_range_and_part_number": GetObject_range_and_part_number, - "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, - "GetObject_mp_part_number_success": GetObject_mp_part_number_success, - "GetObject_mp_part_number_resp_status": GetObject_mp_part_number_resp_status, - "GetObject_non_mp_part_number_1_success": GetObject_non_mp_part_number_1_success, - "GetObject_empty_object_part_number_1": GetObject_empty_object_part_number_1, - "ListObjects_non_existing_bucket": ListObjects_non_existing_bucket, - "ListObjects_with_prefix": ListObjects_with_prefix, - "ListObjects_truncated": ListObjects_truncated, - "ListObjects_paginated": ListObjects_paginated, - "ListObjects_invalid_max_keys": ListObjects_invalid_max_keys, - "ListObjects_max_keys_0": ListObjects_max_keys_0, - "ListObjects_delimiter": ListObjects_delimiter, - "ListObjects_max_keys_none": ListObjects_max_keys_none, - "ListObjects_marker_not_from_obj_list": ListObjects_marker_not_from_obj_list, - "ListObjects_list_all_objs": ListObjects_list_all_objs, - "ListObjects_nested_dir_file_objs": ListObjects_nested_dir_file_objs, - "ListObjects_check_owner": ListObjects_check_owner, - "ListObjects_non_truncated_common_prefixes": ListObjects_non_truncated_common_prefixes, - "ListObjects_should_not_list_pending_mps": ListObjects_should_not_list_pending_mps, - "ListObjects_mp_masking_with_marker": ListObjects_mp_masking_with_marker, - "ListObjects_mp_masking_truncation": ListObjects_mp_masking_truncation, - "ListObjects_mp_masking_delimiter": ListObjects_mp_masking_delimiter, - "ListObjectsV2_non_truncated_common_prefixes": ListObjectsV2_non_truncated_common_prefixes, - "ListObjectsV2_invalid_parent_prefix": ListObjectsV2_invalid_parent_prefix, - "ListObjectsV2_should_not_list_pending_mps": ListObjectsV2_should_not_list_pending_mps, - "ListObjectsV2_mp_masking_start_after": ListObjectsV2_mp_masking_start_after, - "ListObjectsV2_mp_masking_truncation": ListObjectsV2_mp_masking_truncation, - "ListObjectsV2_mp_masking_delimiter": ListObjectsV2_mp_masking_delimiter, - "ListObjects_with_checksum": ListObjects_with_checksum, - "ListObjectsV2_start_after": ListObjectsV2_start_after, - "ListObjectsV2_both_start_after_and_continuation_token": ListObjectsV2_both_start_after_and_continuation_token, - "ListObjectsV2_start_after_not_in_list": ListObjectsV2_start_after_not_in_list, - "ListObjectsV2_start_after_empty_result": ListObjectsV2_start_after_empty_result, - "ListObjectsV2_both_delimiter_and_prefix": ListObjectsV2_both_delimiter_and_prefix, - "ListObjectsV2_single_dir_object_with_delim_and_prefix": ListObjectsV2_single_dir_object_with_delim_and_prefix, - "ListObjectsV2_truncated_common_prefixes": ListObjectsV2_truncated_common_prefixes, - "ListObjectsV2_all_objs_max_keys": ListObjectsV2_all_objs_max_keys, - "ListObjectsV2_list_all_objs": ListObjectsV2_list_all_objs, - "ListObjectsV2_with_owner": ListObjectsV2_with_owner, - "ListObjectsV2_with_checksum": ListObjectsV2_with_checksum, - "ListObjectVersions_VD_success": ListObjectVersions_VD_success, - "DeleteObject_non_existing_object": DeleteObject_non_existing_object, - "DeleteObject_directory_object_noslash": DeleteObject_directory_object_noslash, - "DeleteObject_non_empty_dir_obj": DeleteObject_non_empty_dir_obj, - "DeleteObject_conditional_writes": DeleteObject_conditional_writes, - "DeleteObject_name_too_long": DeleteObject_name_too_long, - "CopyObject_overwrite_same_dir_object": CopyObject_overwrite_same_dir_object, - "CopyObject_overwrite_same_file_object": CopyObject_overwrite_same_file_object, - "DeleteObject_non_existing_dir_object": DeleteObject_non_existing_dir_object, - "DeleteObject_directory_object": DeleteObject_directory_object, - "DeleteObject_success": DeleteObject_success, - "DeleteObject_success_status_code": DeleteObject_success_status_code, - "DeleteObject_incorrect_expected_bucket_owner": DeleteObject_incorrect_expected_bucket_owner, - "DeleteObject_expected_bucket_owner": DeleteObject_expected_bucket_owner, - "DeleteObjects_empty_input": DeleteObjects_empty_input, - "DeleteObjects_non_existing_objects": DeleteObjects_non_existing_objects, - "DeleteObjects_success": DeleteObjects_success, - "CopyObject_non_existing_dst_bucket": CopyObject_non_existing_dst_bucket, - "CopyObject_not_owned_source_bucket": CopyObject_not_owned_source_bucket, - "CopyObject_copy_to_itself": CopyObject_copy_to_itself, - "CopyObject_copy_to_itself_invalid_directive": CopyObject_copy_to_itself_invalid_directive, - "CopyObject_should_replace_tagging": CopyObject_should_replace_tagging, - "CopyObject_should_copy_tagging": CopyObject_should_copy_tagging, - "CopyObject_invalid_tagging_directive": CopyObject_invalid_tagging_directive, - "CopyObject_long_metadata": CopyObject_long_metadata, - "CopyObject_to_itself_with_new_metadata": CopyObject_to_itself_with_new_metadata, - "CopyObject_copy_source_starting_with_slash": CopyObject_copy_source_starting_with_slash, - "CopyObject_invalid_copy_source": CopyObject_invalid_copy_source, - "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, - "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, - "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, - "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, - "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, - "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, - "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, - "CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode, - "CopyObject_with_legal_hold": CopyObject_with_legal_hold, - "CopyObject_with_retention_lock": CopyObject_with_retention_lock, - "CopyObject_conditional_reads": CopyObject_conditional_reads, - "CopyObject_object_acl_not_supported": CopyObject_object_acl_not_supported, - "CopyObject_with_metadata": CopyObject_with_metadata, - "CopyObject_invalid_checksum_algorithm": CopyObject_invalid_checksum_algorithm, - "CopyObject_create_checksum_on_copy": CopyObject_create_checksum_on_copy, - "CopyObject_should_copy_the_existing_checksum": CopyObject_should_copy_the_existing_checksum, - "CopyObject_should_replace_the_existing_checksum": CopyObject_should_replace_the_existing_checksum, - "CopyObject_to_itself_by_replacing_the_checksum": CopyObject_to_itself_by_replacing_the_checksum, - "CopyObject_with_special_characters": CopyObject_with_special_characters, - "CopyObject_success": CopyObject_success, - "CopyObject_incorrect_source_bucket_expected_owner": CopyObject_incorrect_source_bucket_expected_owner, - "PutObjectTagging_non_existing_object": PutObjectTagging_non_existing_object, - "PutObjectTagging_long_tags": PutObjectTagging_long_tags, - "PutObjectTagging_duplicate_keys": PutObjectTagging_duplicate_keys, - "PutObjectTagging_tag_count_limit": PutObjectTagging_tag_count_limit, - "PutObjectTagging_invalid_tags": PutObjectTagging_invalid_tags, - "PutObjectTagging_success": PutObjectTagging_success, - "GetObjectTagging_non_existing_object": GetObjectTagging_non_existing_object, - "GetObjectTagging_unset_tags": GetObjectTagging_unset_tags, - "GetObjectTagging_invalid_parent": GetObjectTagging_invalid_parent, - "GetObjectTagging_success": GetObjectTagging_success, - "DeleteObjectTagging_non_existing_object": DeleteObjectTagging_non_existing_object, - "DeleteObjectTagging_success_status": DeleteObjectTagging_success_status, - "DeleteObjectTagging_success": DeleteObjectTagging_success, - "DeleteObjectTagging_expected_bucket_owner": DeleteObjectTagging_expected_bucket_owner, - "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, - "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, - "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, - "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, - "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, - "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, - "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, - "CreateMultipartUpload_with_object_lock_invalid_retention": CreateMultipartUpload_with_object_lock_invalid_retention, - "CreateMultipartUpload_past_retain_until_date": CreateMultipartUpload_past_retain_until_date, - "CreateMultipartUpload_invalid_legal_hold": CreateMultipartUpload_invalid_legal_hold, - "CreateMultipartUpload_invalid_object_lock_mode": CreateMultipartUpload_invalid_object_lock_mode, - "CreateMultipartUpload_object_acl_not_supported": CreateMultipartUpload_object_acl_not_supported, - "CreateMultipartUpload_invalid_checksum_algorithm": CreateMultipartUpload_invalid_checksum_algorithm, - "CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type": CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type, - "CreateMultipartUpload_type_algo_mismatch": CreateMultipartUpload_type_algo_mismatch, - "CreateMultipartUpload_invalid_checksum_type": CreateMultipartUpload_invalid_checksum_type, - "CreateMultipartUpload_valid_algo_type": CreateMultipartUpload_valid_algo_type, - "CreateMultipartUpload_success": CreateMultipartUpload_success, - "UploadPart_non_existing_bucket": UploadPart_non_existing_bucket, - "UploadPart_invalid_part_number": UploadPart_invalid_part_number, - "UploadPart_non_existing_key": UploadPart_non_existing_key, - "UploadPart_non_existing_mp_upload": UploadPart_non_existing_mp_upload, - "UploadPart_multiple_checksum_headers": UploadPart_multiple_checksum_headers, - "UploadPart_invalid_checksum_header": UploadPart_invalid_checksum_header, - "UploadPart_checksum_header_and_algo_mismatch": UploadPart_checksum_header_and_algo_mismatch, - "UploadPart_checksum_algorithm_mistmatch_on_initialization": UploadPart_checksum_algorithm_mistmatch_on_initialization, - "UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value": UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value, - "UploadPart_incorrect_checksums": UploadPart_incorrect_checksums, - "UploadPart_no_checksum_with_full_object_checksum_type": UploadPart_no_checksum_with_full_object_checksum_type, - "UploadPart_no_checksum_with_composite_checksum_type": UploadPart_no_checksum_with_composite_checksum_type, - "UploadPart_with_checksums_success": UploadPart_with_checksums_success, - "UploadPart_success": UploadPart_success, - "UploadPart_etag_quoting_consistency": UploadPart_etag_quoting_consistency, - "UploadPart_data_integrity_etag": UploadPart_data_integrity_etag, - "UploadPartCopy_non_existing_bucket": UploadPartCopy_non_existing_bucket, - "UploadPartCopy_incorrect_uploadId": UploadPartCopy_incorrect_uploadId, - "UploadPartCopy_incorrect_object_key": UploadPartCopy_incorrect_object_key, - "UploadPartCopy_invalid_part_number": UploadPartCopy_invalid_part_number, - "UploadPartCopy_invalid_copy_source": UploadPartCopy_invalid_copy_source, - "UploadPartCopy_non_existing_source_bucket": UploadPartCopy_non_existing_source_bucket, - "UploadPartCopy_non_existing_source_object_key": UploadPartCopy_non_existing_source_object_key, - "UploadPartCopy_success": UploadPartCopy_success, - "UploadPartCopy_by_range_invalid_ranges": UploadPartCopy_by_range_invalid_ranges, - "UploadPartCopy_exceeding_copy_source_range": UploadPartCopy_exceeding_copy_source_range, - "UploadPartCopy_greater_range_than_obj_size": UploadPartCopy_greater_range_than_obj_size, - "UploadPartCopy_by_range_success": UploadPartCopy_by_range_success, - "UploadPartCopy_conditional_reads": UploadPartCopy_conditional_reads, - "UploadPartCopy_incorrect_source_bucket_expected_owner": UploadPartCopy_incorrect_source_bucket_expected_owner, - "UploadPartCopy_should_copy_the_checksum": UploadPartCopy_should_copy_the_checksum, - "UploadPartCopy_should_not_copy_the_checksum": UploadPartCopy_should_not_copy_the_checksum, - "UploadPartCopy_should_calculate_the_checksum": UploadPartCopy_should_calculate_the_checksum, - "UploadPartCopy_data_integrity_etag": UploadPartCopy_data_integrity_etag, - "ListParts_incorrect_uploadId": ListParts_incorrect_uploadId, - "ListParts_incorrect_object_key": ListParts_incorrect_object_key, - "ListParts_invalid_max_parts": ListParts_invalid_max_parts, - "ListParts_invalid_part_number_marker": ListParts_invalid_part_number_marker, - "ListParts_default_max_parts": ListParts_default_max_parts, - "ListParts_truncated": ListParts_truncated, - "ListParts_with_checksums": ListParts_with_checksums, - "ListParts_null_checksums": ListParts_null_checksums, - "ListParts_success": ListParts_success, - "ListMultipartUploads_non_existing_bucket": ListMultipartUploads_non_existing_bucket, - "ListMultipartUploads_empty_result": ListMultipartUploads_empty_result, - "ListMultipartUploads_invalid_max_uploads": ListMultipartUploads_invalid_max_uploads, - "ListMultipartUploads_max_uploads": ListMultipartUploads_max_uploads, - "ListMultipartUploads_exceeding_max_uploads": ListMultipartUploads_exceeding_max_uploads, - "ListMultipartUploads_ignore_upload_id_marker": ListMultipartUploads_ignore_upload_id_marker, - "ListMultipartUploads_invalid_uploadId_marker": ListMultipartUploads_invalid_uploadId_marker, - "ListMultipartUploads_keyMarker_not_from_list": ListMultipartUploads_keyMarker_not_from_list, - "ListMultipartUploads_delimiter_truncated": ListMultipartUploads_delimiter_truncated, - "ListMultipartUploads_prefix": ListMultipartUploads_prefix, - "ListMultipartUploads_both_delimiter_and_prefix": ListMultipartUploads_both_delimiter_and_prefix, - "ListMultipartUploads_with_checksums": ListMultipartUploads_with_checksums, - "AbortMultipartUpload_non_existing_bucket": AbortMultipartUpload_non_existing_bucket, - "AbortMultipartUpload_incorrect_uploadId": AbortMultipartUpload_incorrect_uploadId, - "AbortMultipartUpload_incorrect_object_key": AbortMultipartUpload_incorrect_object_key, - "AbortMultipartUpload_success": AbortMultipartUpload_success, - "AbortMultipartUpload_success_status_code": AbortMultipartUpload_success_status_code, - "AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time, - "CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket, - "CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number, - "CompleteMultipartUpload_default_content_type": CompleteMultipartUpload_default_content_type, - "CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag, - "CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size, - "CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts, - "CompleteMultipartUpload_missing_part_fields": CompleteMultipartUpload_missing_part_fields, - "CompleteMultipartUpload_incorrect_part_number": CompleteMultipartUpload_incorrect_part_number, - "CompleteMultipartUpload_incorrect_parts_order": CompleteMultipartUpload_incorrect_parts_order, - "CompleteMultipartUpload_mpu_object_size": CompleteMultipartUpload_mpu_object_size, - "CompleteMultipartUpload_conditional_writes": CompleteMultipartUpload_conditional_writes, - "CompleteMultipartUpload_with_metadata": CompleteMultipartUpload_with_metadata, - "CompleteMultipartUpload_invalid_checksum_type": CompleteMultipartUpload_invalid_checksum_type, - "CompleteMultipartUpload_invalid_checksum_part": CompleteMultipartUpload_invalid_checksum_part, - "CompleteMultipartUpload_multiple_checksum_part": CompleteMultipartUpload_multiple_checksum_part, - "CompleteMultipartUpload_incorrect_checksum_part": CompleteMultipartUpload_incorrect_checksum_part, - "CompleteMultipartUpload_different_checksum_part": CompleteMultipartUpload_different_checksum_part, - "CompleteMultipartUpload_missing_part_checksum": CompleteMultipartUpload_missing_part_checksum, - "CompleteMultipartUpload_multiple_final_checksums": CompleteMultipartUpload_multiple_final_checksums, - "CompleteMultipartUpload_invalid_final_checksums": CompleteMultipartUpload_invalid_final_checksums, - "CompleteMultipartUpload_incorrect_final_checksums": CompleteMultipartUpload_incorrect_final_checksums, - "CompleteMultipartUpload_should_calculate_the_final_checksum_full_object": CompleteMultipartUpload_should_calculate_the_final_checksum_full_object, - "CompleteMultipartUpload_should_verify_the_final_checksum": CompleteMultipartUpload_should_verify_the_final_checksum, - "CompleteMultipartUpload_should_verify_final_composite_checksum": CompleteMultipartUpload_should_verify_final_composite_checksum, - "CompleteMultipartUpload_invalid_final_composite_checksum": CompleteMultipartUpload_invalid_final_composite_checksum, - "CompleteMultipartUpload_checksum_type_mismatch": CompleteMultipartUpload_checksum_type_mismatch, - "CompleteMultipartUpload_should_ignore_the_final_checksum": CompleteMultipartUpload_should_ignore_the_final_checksum, - "CompleteMultipartUpload_should_succeed_without_final_checksum_type": CompleteMultipartUpload_should_succeed_without_final_checksum_type, - "CompleteMultipartUpload_success": CompleteMultipartUpload_success, - "CompleteMultipartUpload_data_integrity_etag": CompleteMultipartUpload_data_integrity_etag, - "CompleteMultipartUpload_already_completed": CompleteMultipartUpload_already_completed, - "CompleteMultipartUpload_racey_success": CompleteMultipartUpload_racey_success, - "CompleteMultipartUpload_racey_data_integrity": CompleteMultipartUpload_racey_data_integrity, - "PutBucketAcl_non_existing_bucket": PutBucketAcl_non_existing_bucket, - "PutBucketAcl_disabled": PutBucketAcl_disabled, - "PutBucketAcl_none_of_the_options_specified": PutBucketAcl_none_of_the_options_specified, - "PutBucketAcl_invalid_canned_acl": PutBucketAcl_invalid_canned_acl, - "PutBucketAcl_invalid_acl_canned_and_acp": PutBucketAcl_invalid_acl_canned_and_acp, - "PutBucketAcl_invalid_acl_canned_and_grants": PutBucketAcl_invalid_acl_canned_and_grants, - "PutBucketAcl_invalid_acl_acp_and_grants": PutBucketAcl_invalid_acl_acp_and_grants, - "PutBucketAcl_invalid_owner": PutBucketAcl_invalid_owner, - "PutBucketAcl_invalid_owner_not_in_body": PutBucketAcl_invalid_owner_not_in_body, - "PutBucketAcl_invalid_empty_owner_id_in_body": PutBucketAcl_invalid_empty_owner_id_in_body, - "PutBucketAcl_invalid_permission_in_body": PutBucketAcl_invalid_permission_in_body, - "PutBucketAcl_invalid_grantee_type_in_body": PutBucketAcl_invalid_grantee_type_in_body, - "PutBucketAcl_empty_grantee_ID_in_body": PutBucketAcl_empty_grantee_ID_in_body, - "PutBucketAcl_success_access_denied": PutBucketAcl_success_access_denied, - "PutBucketAcl_success_grants": PutBucketAcl_success_grants, - "PutBucketAcl_success_canned_acl": PutBucketAcl_success_canned_acl, - "PutBucketAcl_success_acp": PutBucketAcl_success_acp, - "GetBucketAcl_non_existing_bucket": GetBucketAcl_non_existing_bucket, - "GetBucketAcl_translation_canned_public_read": GetBucketAcl_translation_canned_public_read, - "GetBucketAcl_translation_canned_public_read_write": GetBucketAcl_translation_canned_public_read_write, - "GetBucketAcl_translation_canned_private": GetBucketAcl_translation_canned_private, - "GetBucketAcl_access_denied": GetBucketAcl_access_denied, - "GetBucketAcl_success": GetBucketAcl_success, - "PutBucketPolicy_non_existing_bucket": PutBucketPolicy_non_existing_bucket, - "PutBucketPolicy_invalid_json": PutBucketPolicy_invalid_json, - "PutBucketPolicy_statement_not_provided": PutBucketPolicy_statement_not_provided, - "PutBucketPolicy_empty_statement": PutBucketPolicy_empty_statement, - "PutBucketPolicy_invalid_effect": PutBucketPolicy_invalid_effect, - "PutBucketPolicy_invalid_action": PutBucketPolicy_invalid_action, - "PutBucketPolicy_empty_principals_string": PutBucketPolicy_empty_principals_string, - "PutBucketPolicy_empty_principals_array": PutBucketPolicy_empty_principals_array, - "PutBucketPolicy_principals_aws_struct_empty_string": PutBucketPolicy_principals_aws_struct_empty_string, - "PutBucketPolicy_principals_aws_struct_empty_string_slice": PutBucketPolicy_principals_aws_struct_empty_string_slice, - "PutBucketPolicy_principals_incorrect_wildcard_usage": PutBucketPolicy_principals_incorrect_wildcard_usage, - "PutBucketPolicy_non_existing_principals": PutBucketPolicy_non_existing_principals, - "PutBucketPolicy_empty_resources_string": PutBucketPolicy_empty_resources_string, - "PutBucketPolicy_empty_resources_array": PutBucketPolicy_empty_resources_array, - "PutBucketPolicy_invalid_resource_prefix": PutBucketPolicy_invalid_resource_prefix, - "PutBucketPolicy_invalid_resource_with_starting_slash": PutBucketPolicy_invalid_resource_with_starting_slash, - "PutBucketPolicy_duplicate_resource": PutBucketPolicy_duplicate_resource, - "PutBucketPolicy_incorrect_bucket_name": PutBucketPolicy_incorrect_bucket_name, - "PutBucketPolicy_action_resource_mismatch": PutBucketPolicy_action_resource_mismatch, - "PutBucketPolicy_explicit_deny": PutBucketPolicy_explicit_deny, - "PutBucketPolicy_multi_wildcard_resource": PutBucketPolicy_multi_wildcard_resource, - "PutBucketPolicy_any_char_match": PutBucketPolicy_any_char_match, - "PutBucketPolicy_version": PutBucketPolicy_version, - "PutBucketPolicy_success": PutBucketPolicy_success, - "PutBucketPolicy_status": PutBucketPolicy_status, - "GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket, - "GetBucketPolicy_not_set": GetBucketPolicy_not_set, - "GetBucketPolicy_success": GetBucketPolicy_success, - "GetBucketPolicyStatus_non_existing_bucket": GetBucketPolicyStatus_non_existing_bucket, - "GetBucketPolicyStatus_no_such_bucket_policy": GetBucketPolicyStatus_no_such_bucket_policy, - "GetBucketPolicyStatus_success": GetBucketPolicyStatus_success, - "DeleteBucketPolicy_non_existing_bucket": DeleteBucketPolicy_non_existing_bucket, - "DeleteBucketPolicy_remove_before_setting": DeleteBucketPolicy_remove_before_setting, - "DeleteBucketPolicy_success": DeleteBucketPolicy_success, - "PutBucketCors_non_existing_bucket": PutBucketCors_non_existing_bucket, - "PutBucketCors_empty_cors_rules": PutBucketCors_empty_cors_rules, - "PutBucketCors_invalid_allowed_origins": PutBucketCors_invalid_allowed_origins, - "PutBucketCors_invalid_method": PutBucketCors_invalid_method, - "PutBucketCors_invalid_header": PutBucketCors_invalid_header, - "PutBucketCors_md5": PutBucketCors_md5, - "GetBucketCors_non_existing_bucket": GetBucketCors_non_existing_bucket, - "GetBucketCors_no_such_bucket_cors": GetBucketCors_no_such_bucket_cors, - "GetBucketCors_success": GetBucketCors_success, - "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, - "DeleteBucketCors_success": DeleteBucketCors_success, - "PutBucketCors_success": PutBucketCors_success, - "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, - "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, - "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, - "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, - "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, - "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, - "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, - "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, - "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, - "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, - "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, - "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code, - "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code, - "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large, - "PutBucketWebsite_success": PutBucketWebsite_success, - "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, - "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, - "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, - "GetBucketWebsite_success": GetBucketWebsite_success, - "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, - "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, - "DeleteBucketWebsite_success": DeleteBucketWebsite_success, - "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, - "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, - "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, - "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, - "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, - "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, - "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, - "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, - "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, - "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, - "WebsiteHosting_object_redirect_location": WebsiteHosting_object_redirect_location, - "WebsiteHosting_index_document": WebsiteHosting_index_document, - "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, - "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, - "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers, - "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted, - "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden, - "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin, - "WebsiteHosting_url_encoded_object_key": WebsiteHosting_url_encoded_object_key, - "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, - "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, - "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, - "PreflightOPTIONS_invalid_request_headers": PreflightOPTIONS_invalid_request_headers, - "PreflightOPTIONS_unset_bucket_cors": PreflightOPTIONS_unset_bucket_cors, - "PreflightOPTIONS_access_forbidden": PreflightOPTIONS_access_forbidden, - "PreflightOPTIONS_access_granted": PreflightOPTIONS_access_granted, - "CORSMiddleware_invalid_method": CORSMiddleware_invalid_method, - "CORSMiddleware_invalid_headers": CORSMiddleware_invalid_headers, - "CORSMiddleware_access_forbidden": CORSMiddleware_access_forbidden, - "CORSMiddleware_access_granted": CORSMiddleware_access_granted, - "PutObjectLockConfiguration_non_existing_bucket": PutObjectLockConfiguration_non_existing_bucket, - "PutObjectLockConfiguration_empty_request_body": PutObjectLockConfiguration_empty_request_body, - "PutObjectLockConfiguration_malformed_body": PutObjectLockConfiguration_malformed_body, - "PutObjectLockConfiguration_not_enabled_on_bucket_creation": PutObjectLockConfiguration_not_enabled_on_bucket_creation, - "PutObjectLockConfiguration_invalid_status": PutObjectLockConfiguration_invalid_status, - "PutObjectLockConfiguration_invalid_mode": PutObjectLockConfiguration_invalid_mode, - "PutObjectLockConfiguration_both_years_and_days": PutObjectLockConfiguration_both_years_and_days, - "PutObjectLockConfiguration_invalid_years_days": PutObjectLockConfiguration_invalid_years_days, - "PutObjectLockConfiguration_success": PutObjectLockConfiguration_success, - "GetObjectLockConfiguration_non_existing_bucket": GetObjectLockConfiguration_non_existing_bucket, - "GetObjectLockConfiguration_unset_config": GetObjectLockConfiguration_unset_config, - "GetObjectLockConfiguration_success": GetObjectLockConfiguration_success, - "PutObjectRetention_non_existing_bucket": PutObjectRetention_non_existing_bucket, - "PutObjectRetention_non_existing_object": PutObjectRetention_non_existing_object, - "PutObjectRetention_unset_bucket_object_lock_config": PutObjectRetention_unset_bucket_object_lock_config, - "PutObjectRetention_expired_retain_until_date": PutObjectRetention_expired_retain_until_date, - "PutObjectRetention_invalid_mode": PutObjectRetention_invalid_mode, - "PutObjectRetention_overwrite_compliance_mode": PutObjectRetention_overwrite_compliance_mode, - "PutObjectRetention_overwrite_compliance_with_compliance": PutObjectRetention_overwrite_compliance_with_compliance, - "PutObjectRetention_overwrite_governance_with_governance": PutObjectRetention_overwrite_governance_with_governance, - "PutObjectRetention_overwrite_governance_without_bypass_specified": PutObjectRetention_overwrite_governance_without_bypass_specified, - "PutObjectRetention_overwrite_governance_with_permission": PutObjectRetention_overwrite_governance_with_permission, - "PutObjectRetention_success": PutObjectRetention_success, - "GetObjectRetention_non_existing_bucket": GetObjectRetention_non_existing_bucket, - "GetObjectRetention_non_existing_object": GetObjectRetention_non_existing_object, - "GetObjectRetention_disabled_lock": GetObjectRetention_disabled_lock, - "GetObjectRetention_unset_config": GetObjectRetention_unset_config, - "GetObjectRetention_success": GetObjectRetention_success, - "PutObjectLegalHold_non_existing_bucket": PutObjectLegalHold_non_existing_bucket, - "PutObjectLegalHold_non_existing_object": PutObjectLegalHold_non_existing_object, - "PutObjectLegalHold_invalid_body": PutObjectLegalHold_invalid_body, - "PutObjectLegalHold_invalid_status": PutObjectLegalHold_invalid_status, - "PutObjectLegalHold_unset_bucket_object_lock_config": PutObjectLegalHold_unset_bucket_object_lock_config, - "PutObjectLegalHold_success": PutObjectLegalHold_success, - "GetObjectLegalHold_non_existing_bucket": GetObjectLegalHold_non_existing_bucket, - "GetObjectLegalHold_non_existing_object": GetObjectLegalHold_non_existing_object, - "GetObjectLegalHold_disabled_lock": GetObjectLegalHold_disabled_lock, - "GetObjectLegalHold_unset_config": GetObjectLegalHold_unset_config, - "GetObjectLegalHold_success": GetObjectLegalHold_success, - "PutBucketAnalyticsConfiguration_not_implemented": PutBucketAnalyticsConfiguration_not_implemented, - "GetBucketAnalyticsConfiguration_not_implemented": GetBucketAnalyticsConfiguration_not_implemented, - "ListBucketAnalyticsConfiguration_not_implemented": ListBucketAnalyticsConfiguration_not_implemented, - "DeleteBucketAnalyticsConfiguration_not_implemented": DeleteBucketAnalyticsConfiguration_not_implemented, - "PutBucketEncryption_not_implemented": PutBucketEncryption_not_implemented, - "GetBucketEncryption_not_implemented": GetBucketEncryption_not_implemented, - "DeleteBucketEncryption_not_implemented": DeleteBucketEncryption_not_implemented, - "PutBucketIntelligentTieringConfiguration_not_implemented": PutBucketIntelligentTieringConfiguration_not_implemented, - "GetBucketIntelligentTieringConfiguration_not_implemented": GetBucketIntelligentTieringConfiguration_not_implemented, - "ListBucketIntelligentTieringConfiguration_not_implemented": ListBucketIntelligentTieringConfiguration_not_implemented, - "DeleteBucketIntelligentTieringConfiguration_not_implemented": DeleteBucketIntelligentTieringConfiguration_not_implemented, - "PutBucketInventoryConfiguration_not_implemented": PutBucketInventoryConfiguration_not_implemented, - "GetBucketInventoryConfiguration_not_implemented": GetBucketInventoryConfiguration_not_implemented, - "ListBucketInventoryConfiguration_not_implemented": ListBucketInventoryConfiguration_not_implemented, - "DeleteBucketInventoryConfiguration_not_implemented": DeleteBucketInventoryConfiguration_not_implemented, - "PutBucketLifecycleConfiguration_not_implemented": PutBucketLifecycleConfiguration_not_implemented, - "GetBucketLifecycleConfiguration_not_implemented": GetBucketLifecycleConfiguration_not_implemented, - "DeleteBucketLifecycle_not_implemented": DeleteBucketLifecycle_not_implemented, - "PutBucketLogging_not_implemented": PutBucketLogging_not_implemented, - "GetBucketLogging_not_implemented": GetBucketLogging_not_implemented, - "PutBucketRequestPayment_not_implemented": PutBucketRequestPayment_not_implemented, - "GetBucketRequestPayment_not_implemented": GetBucketRequestPayment_not_implemented, - "PutBucketMetricsConfiguration_not_implemented": PutBucketMetricsConfiguration_not_implemented, - "GetBucketMetricsConfiguration_not_implemented": GetBucketMetricsConfiguration_not_implemented, - "ListBucketMetricsConfigurations_not_implemented": ListBucketMetricsConfigurations_not_implemented, - "DeleteBucketMetricsConfiguration_not_implemented": DeleteBucketMetricsConfiguration_not_implemented, - "PutBucketReplication_not_implemented": PutBucketReplication_not_implemented, - "GetBucketReplication_not_implemented": GetBucketReplication_not_implemented, - "DeleteBucketReplication_not_implemented": DeleteBucketReplication_not_implemented, - "PutPublicAccessBlock_not_implemented": PutPublicAccessBlock_not_implemented, - "GetPublicAccessBlock_not_implemented": GetPublicAccessBlock_not_implemented, - "DeletePublicAccessBlock_not_implemented": DeletePublicAccessBlock_not_implemented, - "PutBucketNotificationConfiguratio_not_implemented": PutBucketNotificationConfiguratio_not_implemented, - "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, - "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, - "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, - "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, - "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, - "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, - "WORMProtection_bucket_object_lock_configuration_governance_mode": WORMProtection_bucket_object_lock_configuration_governance_mode, - "WORMProtection_bucket_object_lock_governance_bypass_delete": WORMProtection_bucket_object_lock_governance_bypass_delete, - "WORMProtection_bucket_object_lock_governance_bypass_delete_multiple": WORMProtection_bucket_object_lock_governance_bypass_delete_multiple, - "WORMProtection_object_lock_retention_compliance_locked": WORMProtection_object_lock_retention_compliance_locked, - "WORMProtection_object_lock_retention_governance_locked": WORMProtection_object_lock_retention_governance_locked, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_copy": WORMProtection_object_lock_retention_governance_bypass_overwrite_copy, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_mp": WORMProtection_object_lock_retention_governance_bypass_overwrite_mp, - "WORMProtection_unable_to_overwrite_locked_object_put": WORMProtection_unable_to_overwrite_locked_object_put, - "WORMProtection_unable_to_overwrite_locked_object_copy": WORMProtection_unable_to_overwrite_locked_object_copy, - "WORMProtection_unable_to_overwrite_locked_object_mp": WORMProtection_unable_to_overwrite_locked_object_mp, - "WORMProtection_object_lock_retention_governance_bypass_delete": WORMProtection_object_lock_retention_governance_bypass_delete, - "WORMProtection_object_lock_retention_governance_bypass_delete_mul": WORMProtection_object_lock_retention_governance_bypass_delete_mul, - "WORMProtection_object_lock_legal_hold_locked": WORMProtection_object_lock_legal_hold_locked, - "WORMProtection_root_bypass_governance_retention_delete_object": WORMProtection_root_bypass_governance_retention_delete_object, - "PutObject_overwrite_dir_obj": PutObject_overwrite_dir_obj, - "PutObject_overwrite_file_obj": PutObject_overwrite_file_obj, - "PutObject_overwrite_file_obj_with_nested_obj": PutObject_overwrite_file_obj_with_nested_obj, - "PutObject_dir_obj_with_data": PutObject_dir_obj_with_data, - "PutObject_with_slashes": PutObject_with_slashes, - "PutObject_race_with_delete": PutObject_race_with_delete, - "CreateMultipartUpload_dir_obj": CreateMultipartUpload_dir_obj, - "IAM_user_access_denied": IAM_user_access_denied, - "IAM_userplus_access_denied": IAM_userplus_access_denied, - "IAM_userplus_CreateBucket": IAM_userplus_CreateBucket, - "IAM_admin_ChangeBucketOwner": IAM_admin_ChangeBucketOwner, - "IAM_ChangeBucketOwner_back_to_root": IAM_ChangeBucketOwner_back_to_root, - "IAM_ListBuckets": IAM_ListBuckets, - "IAM_CreateBucket_empty_owner_header": IAM_CreateBucket_empty_owner_header, - "IAM_CreateBucket_non_existing_user": IAM_CreateBucket_non_existing_user, - "IAM_CreateBucket_success": IAM_CreateBucket_success, - "AccessControl_default_ACL_user_access_denied": AccessControl_default_ACL_user_access_denied, - "AccessControl_default_ACL_userplus_access_denied": AccessControl_default_ACL_userplus_access_denied, - "AccessControl_default_ACL_admin_successful_access": AccessControl_default_ACL_admin_successful_access, - "AccessControl_bucket_resource_single_action": AccessControl_bucket_resource_single_action, - "AccessControl_bucket_resource_all_action": AccessControl_bucket_resource_all_action, - "AccessControl_single_object_resource_actions": AccessControl_single_object_resource_actions, - "AccessControl_multi_statement_policy": AccessControl_multi_statement_policy, - "AccessControl_bucket_ownership_to_user": AccessControl_bucket_ownership_to_user, - "AccessControl_root_PutBucketAcl": AccessControl_root_PutBucketAcl, - "AccessControl_user_PutBucketAcl_with_policy_access": AccessControl_user_PutBucketAcl_with_policy_access, - "AccessControl_copy_object_with_starting_slash_for_user": AccessControl_copy_object_with_starting_slash_for_user, - "AccessControl_PutObject_with_tagging_policy": AccessControl_PutObject_with_tagging_policy, - "AccessControl_PutObject_with_legal_hold_policy": AccessControl_PutObject_with_legal_hold_policy, - "AccessControl_PutObject_with_retention_policy": AccessControl_PutObject_with_retention_policy, - "AccessControl_CreateMultipartUpload_with_tagging_policy": AccessControl_CreateMultipartUpload_with_tagging_policy, - "AccessControl_CreateMultipartUpload_with_legal_hold_policy": AccessControl_CreateMultipartUpload_with_legal_hold_policy, - "AccessControl_CreateMultipartUpload_with_retention_policy": AccessControl_CreateMultipartUpload_with_retention_policy, - "AccessControl_CopyObject_with_tagging_policy": AccessControl_CopyObject_with_tagging_policy, - "AccessControl_CopyObject_with_legal_hold_policy": AccessControl_CopyObject_with_legal_hold_policy, - "AccessControl_CopyObject_with_retention_policy": AccessControl_CopyObject_with_retention_policy, - "AccessControl_policy_normalizes_object_key_for_get_put_delete": AccessControl_policy_normalizes_object_key_for_get_put_delete, - "PublicBucket_default_private_bucket": PublicBucket_default_private_bucket, - "PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy, - "PublicBucket_public_object_policy": PublicBucket_public_object_policy, - "PublicBucket_public_acl": PublicBucket_public_acl, - "PublicBucket_policy_deny_overrides_public_acl": PublicBucket_policy_deny_overrides_public_acl, - "PublicBucket_signed_streaming_payload": PublicBucket_signed_streaming_payload, - "PublicBucket_incorrect_sha256_hash": PublicBucket_incorrect_sha256_hash, - "PutBucketVersioning_non_existing_bucket": PutBucketVersioning_non_existing_bucket, - "PutBucketVersioning_invalid_status": PutBucketVersioning_invalid_status, - "PutBucketVersioning_success_enabled": PutBucketVersioning_success_enabled, - "PutBucketVersioning_success_suspended": PutBucketVersioning_success_suspended, - "GetBucketVersioning_non_existing_bucket": GetBucketVersioning_non_existing_bucket, - "GetBucketVersioning_empty_response": GetBucketVersioning_empty_response, - "GetBucketVersioning_success": GetBucketVersioning_success, - "Versioning_DeleteBucket_not_empty": Versioning_DeleteBucket_not_empty, - "Versioning_PutObject_suspended_null_versionId_obj": Versioning_PutObject_suspended_null_versionId_obj, - "Versioning_PutObject_null_versionId_obj": Versioning_PutObject_null_versionId_obj, - "Versioning_PutObject_overwrite_null_versionId_obj": Versioning_PutObject_overwrite_null_versionId_obj, - "Versioning_PutObject_success": Versioning_PutObject_success, - "Versioning_CopyObject_invalid_versionId": Versioning_CopyObject_invalid_versionId, - "Versioning_CopyObject_encoded_versionid_separator_invalid_versionId": Versioning_CopyObject_encoded_versionid_separator_invalid_versionId, - "Versioning_CopyObject_success": Versioning_CopyObject_success, - "Versioning_CopyObject_non_existing_version_id": Versioning_CopyObject_non_existing_version_id, - "Versioning_CopyObject_from_an_object_version": Versioning_CopyObject_from_an_object_version, - "Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars, - "Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId, - "Versioning_HeadObject_non_existing_object_version": Versioning_HeadObject_non_existing_object_version, - "Versioning_HeadObject_invalid_parent": Versioning_HeadObject_invalid_parent, - "Versioning_HeadObject_success": Versioning_HeadObject_success, - "Versioning_HeadObject_without_versionId": Versioning_HeadObject_without_versionId, - "Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker, - "Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId, - "Versioning_GetObject_non_existing_object_version": Versioning_GetObject_non_existing_object_version, - "Versioning_GetObject_success": Versioning_GetObject_success, - "Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId, - "Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker, - "Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj, - "Versioning_PutObjectTagging_invalid_versionId": Versioning_PutObjectTagging_invalid_versionId, - "Versioning_PutObjectTagging_non_existing_object_version": Versioning_PutObjectTagging_non_existing_object_version, - "Versioning_PutGetDeleteObjectTagging_delete_marker": Versioning_PutGetDeleteObjectTagging_delete_marker, - "Versioning_GetObjectTagging_invalid_versionId": Versioning_GetObjectTagging_invalid_versionId, - "Versioning_GetObjectTagging_non_existing_object_version": Versioning_GetObjectTagging_non_existing_object_version, - "Versioning_DeleteObjectTagging_invalid_versionId": Versioning_DeleteObjectTagging_invalid_versionId, - "Versioning_DeleteObjectTagging_non_existing_object_version": Versioning_DeleteObjectTagging_non_existing_object_version, - "Versioning_PutGetDeleteObjectTagging_success": Versioning_PutGetDeleteObjectTagging_success, - "Versioning_GetObjectAttributes_invalid_versionId": Versioning_GetObjectAttributes_invalid_versionId, - "Versioning_GetObjectAttributes_object_version": Versioning_GetObjectAttributes_object_version, - "Versioning_GetObjectAttributes_delete_marker": Versioning_GetObjectAttributes_delete_marker, - "Versioning_DeleteObject_invalid_versionId": Versioning_DeleteObject_invalid_versionId, - "Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version, - "Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object, - "Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker, - "Versioning_Delete_null_versionId_object": Versioning_Delete_null_versionId_object, - "Versioning_DeleteObject_nested_dir_object": Versioning_DeleteObject_nested_dir_object, - "Versioning_DeleteObject_non_existing_objects": Versioning_DeleteObject_non_existing_objects, - "Versioning_DeleteObject_suspended": Versioning_DeleteObject_suspended, - "Versioning_DeleteObjects_success": Versioning_DeleteObjects_success, - "Versioning_DeleteObjects_delete_deleteMarkers": Versioning_DeleteObjects_delete_deleteMarkers, - "ListObjectVersions_non_existing_bucket": ListObjectVersions_non_existing_bucket, - "ListObjectVersions_negative_max_keys": ListObjectVersions_negative_max_keys, - "ListObjectVersions_list_single_object_versions": ListObjectVersions_list_single_object_versions, - "ListObjectVersions_list_multiple_object_versions": ListObjectVersions_list_multiple_object_versions, - "ListObjectVersions_multiple_object_versions_truncated": ListObjectVersions_multiple_object_versions_truncated, - "ListObjectVersions_with_delete_markers": ListObjectVersions_with_delete_markers, - "ListObjectVersions_containing_null_versionId_obj": ListObjectVersions_containing_null_versionId_obj, - "ListObjectVersions_single_null_versionId_object": ListObjectVersions_single_null_versionId_object, - "ListObjectVersions_checksum": ListObjectVersions_checksum, - "Versioning_Multipart_Upload_success": Versioning_Multipart_Upload_success, - "Versioning_Multipart_Upload_overwrite_an_object": Versioning_Multipart_Upload_overwrite_an_object, - "Versioning_UploadPartCopy_invalid_versionId": Versioning_UploadPartCopy_invalid_versionId, - "Versioning_UploadPartCopy_encoded_versionid_separator_invalid_versionId": Versioning_UploadPartCopy_encoded_versionid_separator_invalid_versionId, - "Versioning_UploadPartCopy_non_existing_versionId": Versioning_UploadPartCopy_non_existing_versionId, - "Versioning_UploadPartCopy_from_an_object_version": Versioning_UploadPartCopy_from_an_object_version, - "Versioning_object_lock_not_enabled_on_bucket_creation": Versioning_object_lock_not_enabled_on_bucket_creation, - "Versioning_Enable_object_lock": Versioning_Enable_object_lock, - "Versioning_status_switch_to_suspended_with_object_lock": Versioning_status_switch_to_suspended_with_object_lock, - "Versioning_PutObjectRetention_invalid_versionId": Versioning_PutObjectRetention_invalid_versionId, - "Versioning_PutObjectRetention_non_existing_object_version": Versioning_PutObjectRetention_non_existing_object_version, - "Versioning_GetObjectRetention_invalid_versionId": Versioning_GetObjectRetention_invalid_versionId, - "Versioning_GetObjectRetention_non_existing_object_version": Versioning_GetObjectRetention_non_existing_object_version, - "Versioning_Put_GetObjectRetention_delete_marker": Versioning_Put_GetObjectRetention_delete_marker, - "Versioning_Put_GetObjectRetention_success": Versioning_Put_GetObjectRetention_success, - "Versioning_PutObjectLegalHold_invalid_versionId": Versioning_PutObjectLegalHold_invalid_versionId, - "Versioning_PutObjectLegalHold_non_existing_object_version": Versioning_PutObjectLegalHold_non_existing_object_version, - "Versioning_GetObjectLegalHold_invalid_versionId": Versioning_GetObjectLegalHold_invalid_versionId, - "Versioning_GetObjectLegalHold_non_existing_object_version": Versioning_GetObjectLegalHold_non_existing_object_version, - "Versioning_PutGetObjectLegalHold_delete_marker": Versioning_PutGetObjectLegalHold_delete_marker, - "Versioning_Put_GetObjectLegalHold_success": Versioning_Put_GetObjectLegalHold_success, - "Versioning_WORM_obj_version_locked_with_legal_hold": Versioning_WORM_obj_version_locked_with_legal_hold, - "Versioning_WORM_obj_version_locked_with_governance_retention": Versioning_WORM_obj_version_locked_with_governance_retention, - "Versioning_WORM_obj_version_locked_with_compliance_retention": Versioning_WORM_obj_version_locked_with_compliance_retention, - "Versioning_WORM_delete_marker_locked_object_legal_hold": Versioning_WORM_delete_marker_locked_object_legal_hold, - "Versioning_WORM_delete_marker_locked_object_governance_retention": Versioning_WORM_delete_marker_locked_object_governance_retention, - "Versioning_WORM_delete_marker_locked_object_compliance_retention": Versioning_WORM_delete_marker_locked_object_compliance_retention, - "Versioning_WORM_PutObject_overwrite_locked_object": Versioning_WORM_PutObject_overwrite_locked_object, - "Versioning_WORM_CopyObject_overwrite_locked_object": Versioning_WORM_CopyObject_overwrite_locked_object, - "Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object": Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object, - "Versioning_WORM_remove_delete_marker_under_bucket_default_retention": Versioning_WORM_remove_delete_marker_under_bucket_default_retention, - "Versioning_AccessControl_GetObjectVersion": Versioning_AccessControl_GetObjectVersion, - "Versioning_AccessControl_HeadObjectVersion": Versioning_AccessControl_HeadObjectVersion, - "Versioning_AccessControl_object_tagging_policy": Versioning_AccessControl_object_tagging_policy, - "Versioning_AccessControl_DeleteObject_policy": Versioning_AccessControl_DeleteObject_policy, - "Versioning_AccessControl_GetObjectAttributes_policy": Versioning_AccessControl_GetObjectAttributes_policy, - "Versioning_concurrent_upload_object": Versioning_concurrent_upload_object, - "RouterPutPartNumberWithoutUploadId": RouterPutPartNumberWithoutUploadId, - "RouterPostRoot": RouterPostRoot, - "RouterPostObjectWithoutQuery": RouterPostObjectWithoutQuery, - "RouterPUTObjectOnlyUploadId": RouterPUTObjectOnlyUploadId, - "RouterGetUploadsWithKey": RouterGetUploadsWithKey, - "RouterCopySourceNotAllowed": RouterCopySourceNotAllowed, - "RouterListVersionsWithKey": RouterListVersionsWithKey, - "UnsignedStreaminPayloadTrailer_malformed_trailer": UnsignedStreaminPayloadTrailer_malformed_trailer, - "UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length": UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length, - "UnsignedStreamingPayloadTrailer_invalid_trailing_checksum": UnsignedStreamingPayloadTrailer_invalid_trailing_checksum, - "UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum": UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum, - "UnsignedStreamingPayloadTrailer_multiple_checksum_headers": UnsignedStreamingPayloadTrailer_multiple_checksum_headers, - "UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch": UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch, - "UnsignedStreamingPayloadTrailer_incomplete_body": UnsignedStreamingPayloadTrailer_incomplete_body, - "UnsignedStreamingPayloadTrailer_invalid_chunk_size": UnsignedStreamingPayloadTrailer_invalid_chunk_size, - "UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch": UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch, - "UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme": UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme, - "UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers": UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers, - "UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer": UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer, - "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum, - "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object, - "UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch": UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch, - "UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer": UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer, - "UnsignedStreamingPayloadTrailer_not_allowed": UnsignedStreamingPayloadTrailer_not_allowed, - "SignedStreamingPayload_invalid_encoding": SignedStreamingPayload_invalid_encoding, - "SignedStreamingPayload_invalid_chunk_size": SignedStreamingPayload_invalid_chunk_size, - "SignedStreamingPayload_decoded_content_length_mismatch": SignedStreamingPayload_decoded_content_length_mismatch, - "SignedStreamingPayloadTrailer_malformed_trailer": SignedStreamingPayloadTrailer_malformed_trailer, - "SignedStreamingPayloadTrailer_incomplete_body": SignedStreamingPayloadTrailer_incomplete_body, - "SignedStreamingPayloadTrailer_missing_x_amz_trailer_header": SignedStreamingPayloadTrailer_missing_x_amz_trailer_header, - "SignedStreamingPayloadTrailer_invalid_checksum": SignedStreamingPayloadTrailer_invalid_checksum, - "SignedStreamingPayloadTrailer_bad_digest": SignedStreamingPayloadTrailer_bad_digest, - "SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success, - "NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl, - "NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl, - "Server_large_http_header": Server_large_http_header, - "PostObject_invalid_content_type": PostObject_invalid_content_type, - "PostObject_missing_boundary": PostObject_missing_boundary, - "PostObject_partial_auth_fields": PostObject_partial_auth_fields, - "PostObject_invalid_algorithm": PostObject_invalid_algorithm, - "PostObject_invalid_date": PostObject_invalid_date, - "PostObject_invalid_credential_format": PostObject_invalid_credential_format, - "PostObject_incorrect_region": PostObject_incorrect_region, - "PostObject_non_existing_access_key": PostObject_non_existing_access_key, - "PostObject_signature_mismatch": PostObject_signature_mismatch, - "PostObject_expired_due_to_date": PostObject_expired_due_to_date, - "PostObject_access_denied": PostObject_access_denied, - "PostObject_invalid_object_names": PostObject_invalid_object_names, - "PostObject_policy_access_control": PostObject_policy_access_control, - "PostObject_policy_expired": PostObject_policy_expired, - "PostObject_invalid_policy_document": PostObject_invalid_policy_document, - "PostObject_policy_condition_key_mismatch": PostObject_policy_condition_key_mismatch, - "PostObject_policy_extra_field": PostObject_policy_extra_field, - "PostObject_policy_missing_bucket_condition": PostObject_policy_missing_bucket_condition, - "PostObject_policy_content_length_too_large": PostObject_policy_content_length_too_large, - "PostObject_policy_content_length_too_small": PostObject_policy_content_length_too_small, - "PostObject_success": PostObject_success, - "PostObject_success_status_200": PostObject_success_status_200, - "PostObject_success_status_201": PostObject_success_status_201, - "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, - "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, - "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, - "PostObject_invalid_tagging": PostObject_invalid_tagging, - "PostObject_success_with_tagging": PostObject_success_with_tagging, - "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, - "PostObject_invalid_checksum_algorithm": PostObject_invalid_checksum_algorithm, - "PostObject_multiple_checksum_headers": PostObject_multiple_checksum_headers, - "PostObject_checksums_success": PostObject_checksums_success, - "PostObject_success_double_dash_boundary": PostObject_success_double_dash_boundary, + "Authentication_invalid_auth_header": Authentication_invalid_auth_header, + "Authentication_unsupported_signature_version": Authentication_unsupported_signature_version, + "Authentication_missing_components": Authentication_missing_components, + "Authentication_malformed_component": Authentication_malformed_component, + "Authentication_missing_credentials": Authentication_missing_credentials, + "Authentication_missing_signedheaders": Authentication_missing_signedheaders, + "Authentication_missing_signature": Authentication_missing_signature, + "Authentication_malformed_credential": Authentication_malformed_credential, + "Authentication_credentials_invalid_terminal": Authentication_credentials_invalid_terminal, + "Authentication_credentials_incorrect_service": Authentication_credentials_incorrect_service, + "Authentication_credentials_incorrect_region": Authentication_credentials_incorrect_region, + "Authentication_credentials_invalid_date": Authentication_credentials_invalid_date, + "Authentication_credentials_future_date": Authentication_credentials_future_date, + "Authentication_credentials_past_date": Authentication_credentials_past_date, + "Authentication_credentials_non_existing_access_key": Authentication_credentials_non_existing_access_key, + "Authentication_missing_date_header": Authentication_missing_date_header, + "Authentication_invalid_date_header": Authentication_invalid_date_header, + "Authentication_date_mismatch": Authentication_date_mismatch, + "Authentication_incorrect_payload_hash": Authentication_incorrect_payload_hash, + "Authentication_invalid_sha256_payload_hash": Authentication_invalid_sha256_payload_hash, + "Authentication_unsigned_required_header": Authentication_unsigned_required_header, + "Authentication_unsigned_non_required_header": Authentication_unsigned_non_required_header, + "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_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, + "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_has_access_keys": IAMDeleteUser_has_access_keys, + "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, + "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, + "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, + "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, + "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, + "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, + "IAMCreateAccessKey_success": IAMCreateAccessKey_success, + "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, + "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, + "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, + "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, + "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, + "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, + "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, + "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, + "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, + "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, + "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, + "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, + "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, + "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, + "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, + "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, + "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, + "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, + "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, + "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, + "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, + "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, + "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, + "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, + "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, + "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, + "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, + "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, + "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, + "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, + "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, + "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, + "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, + "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, + "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, + "IAMListAccessKeys_success": IAMListAccessKeys_success, + "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, + "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, + "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, + "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, + "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, + "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, + "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, + "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, + "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, + "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, + "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, + "IAMPutUserPolicy_success": IAMPutUserPolicy_success, + "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, + "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, + "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, + "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, + "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, + "IAMGetUserPolicy_success": IAMGetUserPolicy_success, + "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, + "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, + "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, + "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, + "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, + "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, + "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, + "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, + "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, + "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, + "IAMListUserPolicies_success": IAMListUserPolicies_success, + "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, + "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, + "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, + "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, + "IAMCreateRole_already_exists": IAMCreateRole_already_exists, + "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, + "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, + "IAMCreateRole_long_path": IAMCreateRole_long_path, + "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, + "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, + "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, + "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, + "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, + "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, + "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, + "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, + "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, + "IAMCreateRole_success": IAMCreateRole_success, + "IAMCreateRole_defaults": IAMCreateRole_defaults, + "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, + "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, + "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, + "IAMGetRole_long_role_name": IAMGetRole_long_role_name, + "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, + "IAMGetRole_success": IAMGetRole_success, + "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, + "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, + "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, + "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, + "IAMListRoles_empty_result": IAMListRoles_empty_result, + "IAMListRoles_success": IAMListRoles_success, + "IAMListRoles_path_prefix": IAMListRoles_path_prefix, + "IAMListRoles_pagination": IAMListRoles_pagination, + "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, + "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, + "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, + "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, + "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, + "IAMDeleteRole_success": IAMDeleteRole_success, + "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, + "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, + "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, + "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, + "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, + "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, + "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, + "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, + "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, + "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, + "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, + "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, + "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, + "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, + "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, + "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, + "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, + "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, + "IAMPutRolePolicy_success": IAMPutRolePolicy_success, + "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, + "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, + "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, + "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, + "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, + "IAMGetRolePolicy_success": IAMGetRolePolicy_success, + "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, + "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, + "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, + "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, + "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, + "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, + "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, + "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, + "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, + "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, + "IAMListRolePolicies_success": IAMListRolePolicies_success, + "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, + "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, + "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, + "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, + "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, + "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, + "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, + "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, + "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, + "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, + "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, + "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, + "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, + "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, + "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, + "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, + "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, + "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, + "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, + "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, + "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, + "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, + "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, + "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, + "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, + "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, + "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, + "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, + "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, + "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, + "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, + "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, + "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, + "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, + "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, + "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, + "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, + "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, + "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, + "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, + "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, + "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, + "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, + "PresignedAuth_missing_signature_query_param": PresignedAuth_missing_signature_query_param, + "PresignedAuth_missing_credentials_query_param": PresignedAuth_missing_credentials_query_param, + "PresignedAuth_malformed_creds_invalid_parts": PresignedAuth_malformed_creds_invalid_parts, + "PresignedAuth_creds_invalid_terminal": PresignedAuth_creds_invalid_terminal, + "PresignedAuth_creds_incorrect_service": PresignedAuth_creds_incorrect_service, + "PresignedAuth_creds_incorrect_region": PresignedAuth_creds_incorrect_region, + "PresignedAuth_creds_invalid_date": PresignedAuth_creds_invalid_date, + "PresignedAuth_missing_date_query": PresignedAuth_missing_date_query, + "PresignedAuth_dates_mismatch": PresignedAuth_dates_mismatch, + "PresignedAuth_non_existing_access_key_id": PresignedAuth_non_existing_access_key_id, + "PresignedAuth_missing_signed_headers_query_param": PresignedAuth_missing_signed_headers_query_param, + "PresignedAuth_unsigned_required_header": PresignedAuth_unsigned_required_header, + "PresignedAuth_unsigned_non_required_header": PresignedAuth_unsigned_non_required_header, + "PresignedAuth_missing_expiration_query_param": PresignedAuth_missing_expiration_query_param, + "PresignedAuth_invalid_expiration_query_param": PresignedAuth_invalid_expiration_query_param, + "PresignedAuth_negative_expiration_query_param": PresignedAuth_negative_expiration_query_param, + "PresignedAuth_exceeding_expiration_query_param": PresignedAuth_exceeding_expiration_query_param, + "PresignedAuth_expired_request": PresignedAuth_expired_request, + "PresignedAuth_incorrect_secret_key": PresignedAuth_incorrect_secret_key, + "PresignedAuth_sigv2_not_supported": PresignedAuth_sigv2_not_supported, + "PresignedAuth_PutObject_success": PresignedAuth_PutObject_success, + "PutObject_missing_object_lock_retention_config": PutObject_missing_object_lock_retention_config, + "PutObject_name_too_long": PutObject_name_too_long, + "PutObject_with_object_lock": PutObject_with_object_lock, + "PutObject_missing_bucket_lock": PutObject_missing_bucket_lock, + "PutObject_invalid_legal_hold": PutObject_invalid_legal_hold, + "PutObject_invalid_object_lock_mode": PutObject_invalid_object_lock_mode, + "PutObject_past_retain_until_date": PutObject_past_retain_until_date, + "PutObject_invalid_retain_until_date": PutObject_invalid_retain_until_date, + "PutObject_conditional_writes": PutObject_conditional_writes, + "PutObject_should_combine_metadata": PutObject_should_combine_metadata, + "PutObject_md5": PutObject_md5, + "PutObject_long_metadata": PutObject_long_metadata, + "PutObject_with_metadata": PutObject_with_metadata, + "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, + "PutObject_invalid_credentials": PutObject_invalid_credentials, + "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, + "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, + "PutObject_invalid_checksum_header": PutObject_invalid_checksum_header, + "PutObject_incorrect_checksums": PutObject_incorrect_checksums, + "PutObject_default_checksum": PutObject_default_checksum, + "PutObject_data_integrity_etag": PutObject_data_integrity_etag, + "PutObject_dir_object_data_integrity_etag": PutObject_dir_object_data_integrity_etag, + "PutObject_dir_object_default_checksum": PutObject_dir_object_default_checksum, + "PutObject_checksums_success": PutObject_checksums_success, + "PutObject_dir_object_checksums_success": PutObject_dir_object_checksums_success, + "PresignedAuth_Put_GetObject_with_data": PresignedAuth_Put_GetObject_with_data, + "PresignedAuth_Put_GetObject_with_UTF8_chars": PresignedAuth_Put_GetObject_with_UTF8_chars, + "PresignedAuth_UploadPart": PresignedAuth_UploadPart, + "CreateBucket_invalid_bucket_name": CreateBucket_invalid_bucket_name, + "CreateBucket_existing_bucket": CreateBucket_existing_bucket, + "CreateBucket_owned_by_you": CreateBucket_owned_by_you, + "CreateBucket_invalid_ownership": CreateBucket_invalid_ownership, + "CreateBucket_ownership_with_acl": CreateBucket_ownership_with_acl, + "CreateBucket_as_user": CreateBucket_as_user, + "CreateBucket_success": CreateBucket_success, + "CreateBucket_default_acl": CreateBucket_default_acl, + "CreateBucket_non_default_acl": CreateBucket_non_default_acl, + "CreateBucket_private_canned_acl": CreateBucket_private_canned_acl, + "CreateBucket_private_canned_acl_bucket_owner_enforced_ownership": CreateBucket_private_canned_acl_bucket_owner_enforced_ownership, + "CreateBucket_default_object_lock": CreateBucket_default_object_lock, + "CreateBucket_invalid_location_constraint": CreateBucket_invalid_location_constraint, + "CreateBucket_long_tags": CreateBucket_long_tags, + "CreateBucket_invalid_tags": CreateBucket_invalid_tags, + "CreateBucket_duplicate_keys": CreateBucket_duplicate_keys, + "CreateBucket_tag_count_limit": CreateBucket_tag_count_limit, + "CreateBucket_invalid_canned_acl": CreateBucket_invalid_canned_acl, + "HeadBucket_non_existing_bucket": HeadBucket_non_existing_bucket, + "HeadBucket_success": HeadBucket_success, + "ListBuckets_as_user": ListBuckets_as_user, + "ListBuckets_as_admin": ListBuckets_as_admin, + "ListBuckets_with_prefix": ListBuckets_with_prefix, + "ListBuckets_invalid_max_buckets": ListBuckets_invalid_max_buckets, + "ListBuckets_truncated": ListBuckets_truncated, + "ListBuckets_success": ListBuckets_success, + "ListBuckets_empty_success": ListBuckets_empty_success, + "DeleteBucket_non_existing_bucket": DeleteBucket_non_existing_bucket, + "DeleteBucket_non_empty_bucket": DeleteBucket_non_empty_bucket, + "DeleteBucket_incorrect_expected_bucket_owner": DeleteBucket_incorrect_expected_bucket_owner, + "DeleteBucket_success_status_code": DeleteBucket_success_status_code, + "PutBucketOwnershipControls_non_existing_bucket": PutBucketOwnershipControls_non_existing_bucket, + "PutBucketOwnershipControls_multiple_rules": PutBucketOwnershipControls_multiple_rules, + "PutBucketOwnershipControls_invalid_ownership": PutBucketOwnershipControls_invalid_ownership, + "PutBucketOwnershipControls_empty_rules": PutBucketOwnershipControls_empty_rules, + "PutBucketOwnershipControls_success": PutBucketOwnershipControls_success, + "GetBucketOwnershipControls_non_existing_bucket": GetBucketOwnershipControls_non_existing_bucket, + "GetBucketOwnershipControls_default_ownership": GetBucketOwnershipControls_default_ownership, + "GetBucketOwnershipControls_success": GetBucketOwnershipControls_success, + "DeleteBucketOwnershipControls_non_existing_bucket": DeleteBucketOwnershipControls_non_existing_bucket, + "DeleteBucketOwnershipControls_success": DeleteBucketOwnershipControls_success, + "PutBucketTagging_non_existing_bucket": PutBucketTagging_non_existing_bucket, + "PutBucketTagging_long_tags": PutBucketTagging_long_tags, + "PutBucketTagging_invalid_tags": PutBucketTagging_invalid_tags, + "PutBucketTagging_duplicate_keys": PutBucketTagging_duplicate_keys, + "PutBucketTagging_tag_count_limit": PutBucketTagging_tag_count_limit, + "PutBucketTagging_success": PutBucketTagging_success, + "PutBucketTagging_success_status": PutBucketTagging_success_status, + "GetBucketTagging_non_existing_bucket": GetBucketTagging_non_existing_bucket, + "GetBucketTagging_unset_tags": GetBucketTagging_unset_tags, + "GetBucketTagging_success": GetBucketTagging_success, + "DeleteBucketTagging_non_existing_object": DeleteBucketTagging_non_existing_object, + "DeleteBucketTagging_success_status": DeleteBucketTagging_success_status, + "DeleteBucketTagging_success": DeleteBucketTagging_success, + "GetBucketLocation_success": GetBucketLocation_success, + "GetBucketLocation_non_exist": GetBucketLocation_non_exist, + "GetBucketLocation_no_access": GetBucketLocation_no_access, + "PutObject_non_existing_bucket": PutObject_non_existing_bucket, + "PutObject_special_chars": PutObject_special_chars, + "PutObject_tagging": PutObject_tagging, + "PutObject_success": PutObject_success, + "PutObject_default_content_type": PutObject_default_content_type, + "PutObject_invalid_object_names": PutObject_invalid_object_names, + "PutObject_object_acl_not_supported": PutObject_object_acl_not_supported, + "PutObject_false_negative_object_names": PutObject_false_negative_object_names, + "PutObject_racey_success": PutObject_racey_success, + "HeadObject_non_existing_object": HeadObject_non_existing_object, + "HeadObject_invalid_part_number": HeadObject_invalid_part_number, + "HeadObject_directory_object_noslash": HeadObject_directory_object_noslash, + "HeadObject_non_existing_dir_object": HeadObject_non_existing_dir_object, + "HeadObject_incidental_dir_object": HeadObject_incidental_dir_object, + "HeadObject_name_too_long": HeadObject_name_too_long, + "HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir, + "HeadObject_with_range": HeadObject_with_range, + "HeadObject_by_range_resp_status": HeadObject_by_range_resp_status, + "HeadObject_zero_len_with_range": HeadObject_zero_len_with_range, + "HeadObject_dir_with_range": HeadObject_dir_with_range, + "HeadObject_conditional_reads": HeadObject_conditional_reads, + "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, + "HeadObject_checksums": HeadObject_checksums, + "HeadObject_ranged_with_checksum_mode": HeadObject_ranged_with_checksum_mode, + "HeadObject_success": HeadObject_success, + "HeadObject_overrides_success": HeadObject_overrides_success, + "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, + "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, + "HeadObject_range_and_part_number": HeadObject_range_and_part_number, + "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, + "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, + "HeadObject_mp_part_number_resp_status": HeadObject_mp_part_number_resp_status, + "HeadObject_non_mp_part_number_1_success": HeadObject_non_mp_part_number_1_success, + "HeadObject_empty_object_part_number_1": HeadObject_empty_object_part_number_1, + "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, + "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, + "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs, + "GetObjectAttributes_invalid_parent": GetObjectAttributes_invalid_parent, + "GetObjectAttributes_invalid_single_attribute": GetObjectAttributes_invalid_single_attribute, + "GetObjectAttributes_empty_attrs": GetObjectAttributes_empty_attrs, + "GetObjectAttributes_existing_object": GetObjectAttributes_existing_object, + "GetObjectAttributes_checksums": GetObjectAttributes_checksums, + "GetObject_non_existing_key": GetObject_non_existing_key, + "GetObject_directory_object_noslash": GetObject_directory_object_noslash, + "GetObject_with_range": GetObject_with_range, + "GetObject_zero_len_with_range": GetObject_zero_len_with_range, + "GetObject_dir_with_range": GetObject_dir_with_range, + "GetObject_invalid_parent": GetObject_invalid_parent, + "GetObject_large_object": GetObject_large_object, + "GetObject_conditional_reads": GetObject_conditional_reads, + "GetObject_not_enabled_checksum_mode": GetObject_not_enabled_checksum_mode, + "GetObject_checksums": GetObject_checksums, + "GetObject_dir_object_checksum": GetObject_dir_object_checksum, + "GetObject_ranged_with_checksum_mode": GetObject_ranged_with_checksum_mode, + "GetObject_success": GetObject_success, + "GetObject_directory_success": GetObject_directory_success, + "GetObject_by_range_resp_status": GetObject_by_range_resp_status, + "GetObject_non_existing_dir_object": GetObject_non_existing_dir_object, + "GetObject_incidental_dir_object": GetObject_incidental_dir_object, + "GetObject_overrides_success": GetObject_overrides_success, + "GetObject_overrides_presign_success": GetObject_overrides_presign_success, + "GetObject_overrides_fail_public": GetObject_overrides_fail_public, + "GetObject_invalid_part_number": GetObject_invalid_part_number, + "GetObject_range_and_part_number": GetObject_range_and_part_number, + "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, + "GetObject_mp_part_number_success": GetObject_mp_part_number_success, + "GetObject_mp_part_number_resp_status": GetObject_mp_part_number_resp_status, + "GetObject_non_mp_part_number_1_success": GetObject_non_mp_part_number_1_success, + "GetObject_empty_object_part_number_1": GetObject_empty_object_part_number_1, + "ListObjects_non_existing_bucket": ListObjects_non_existing_bucket, + "ListObjects_with_prefix": ListObjects_with_prefix, + "ListObjects_truncated": ListObjects_truncated, + "ListObjects_paginated": ListObjects_paginated, + "ListObjects_invalid_max_keys": ListObjects_invalid_max_keys, + "ListObjects_max_keys_0": ListObjects_max_keys_0, + "ListObjects_delimiter": ListObjects_delimiter, + "ListObjects_max_keys_none": ListObjects_max_keys_none, + "ListObjects_marker_not_from_obj_list": ListObjects_marker_not_from_obj_list, + "ListObjects_list_all_objs": ListObjects_list_all_objs, + "ListObjects_nested_dir_file_objs": ListObjects_nested_dir_file_objs, + "ListObjects_check_owner": ListObjects_check_owner, + "ListObjects_non_truncated_common_prefixes": ListObjects_non_truncated_common_prefixes, + "ListObjects_should_not_list_pending_mps": ListObjects_should_not_list_pending_mps, + "ListObjects_mp_masking_with_marker": ListObjects_mp_masking_with_marker, + "ListObjects_mp_masking_truncation": ListObjects_mp_masking_truncation, + "ListObjects_mp_masking_delimiter": ListObjects_mp_masking_delimiter, + "ListObjectsV2_non_truncated_common_prefixes": ListObjectsV2_non_truncated_common_prefixes, + "ListObjectsV2_invalid_parent_prefix": ListObjectsV2_invalid_parent_prefix, + "ListObjectsV2_should_not_list_pending_mps": ListObjectsV2_should_not_list_pending_mps, + "ListObjectsV2_mp_masking_start_after": ListObjectsV2_mp_masking_start_after, + "ListObjectsV2_mp_masking_truncation": ListObjectsV2_mp_masking_truncation, + "ListObjectsV2_mp_masking_delimiter": ListObjectsV2_mp_masking_delimiter, + "ListObjects_with_checksum": ListObjects_with_checksum, + "ListObjectsV2_start_after": ListObjectsV2_start_after, + "ListObjectsV2_both_start_after_and_continuation_token": ListObjectsV2_both_start_after_and_continuation_token, + "ListObjectsV2_start_after_not_in_list": ListObjectsV2_start_after_not_in_list, + "ListObjectsV2_start_after_empty_result": ListObjectsV2_start_after_empty_result, + "ListObjectsV2_both_delimiter_and_prefix": ListObjectsV2_both_delimiter_and_prefix, + "ListObjectsV2_single_dir_object_with_delim_and_prefix": ListObjectsV2_single_dir_object_with_delim_and_prefix, + "ListObjectsV2_truncated_common_prefixes": ListObjectsV2_truncated_common_prefixes, + "ListObjectsV2_all_objs_max_keys": ListObjectsV2_all_objs_max_keys, + "ListObjectsV2_list_all_objs": ListObjectsV2_list_all_objs, + "ListObjectsV2_with_owner": ListObjectsV2_with_owner, + "ListObjectsV2_with_checksum": ListObjectsV2_with_checksum, + "ListObjectVersions_VD_success": ListObjectVersions_VD_success, + "DeleteObject_non_existing_object": DeleteObject_non_existing_object, + "DeleteObject_directory_object_noslash": DeleteObject_directory_object_noslash, + "DeleteObject_non_empty_dir_obj": DeleteObject_non_empty_dir_obj, + "DeleteObject_conditional_writes": DeleteObject_conditional_writes, + "DeleteObject_name_too_long": DeleteObject_name_too_long, + "CopyObject_overwrite_same_dir_object": CopyObject_overwrite_same_dir_object, + "CopyObject_overwrite_same_file_object": CopyObject_overwrite_same_file_object, + "DeleteObject_non_existing_dir_object": DeleteObject_non_existing_dir_object, + "DeleteObject_directory_object": DeleteObject_directory_object, + "DeleteObject_success": DeleteObject_success, + "DeleteObject_success_status_code": DeleteObject_success_status_code, + "DeleteObject_incorrect_expected_bucket_owner": DeleteObject_incorrect_expected_bucket_owner, + "DeleteObject_expected_bucket_owner": DeleteObject_expected_bucket_owner, + "DeleteObjects_empty_input": DeleteObjects_empty_input, + "DeleteObjects_non_existing_objects": DeleteObjects_non_existing_objects, + "DeleteObjects_success": DeleteObjects_success, + "CopyObject_non_existing_dst_bucket": CopyObject_non_existing_dst_bucket, + "CopyObject_not_owned_source_bucket": CopyObject_not_owned_source_bucket, + "CopyObject_copy_to_itself": CopyObject_copy_to_itself, + "CopyObject_copy_to_itself_invalid_directive": CopyObject_copy_to_itself_invalid_directive, + "CopyObject_should_replace_tagging": CopyObject_should_replace_tagging, + "CopyObject_should_copy_tagging": CopyObject_should_copy_tagging, + "CopyObject_invalid_tagging_directive": CopyObject_invalid_tagging_directive, + "CopyObject_long_metadata": CopyObject_long_metadata, + "CopyObject_to_itself_with_new_metadata": CopyObject_to_itself_with_new_metadata, + "CopyObject_copy_source_starting_with_slash": CopyObject_copy_source_starting_with_slash, + "CopyObject_invalid_copy_source": CopyObject_invalid_copy_source, + "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, + "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, + "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, + "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, + "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, + "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, + "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, + "CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode, + "CopyObject_with_legal_hold": CopyObject_with_legal_hold, + "CopyObject_with_retention_lock": CopyObject_with_retention_lock, + "CopyObject_conditional_reads": CopyObject_conditional_reads, + "CopyObject_object_acl_not_supported": CopyObject_object_acl_not_supported, + "CopyObject_with_metadata": CopyObject_with_metadata, + "CopyObject_invalid_checksum_algorithm": CopyObject_invalid_checksum_algorithm, + "CopyObject_create_checksum_on_copy": CopyObject_create_checksum_on_copy, + "CopyObject_should_copy_the_existing_checksum": CopyObject_should_copy_the_existing_checksum, + "CopyObject_should_replace_the_existing_checksum": CopyObject_should_replace_the_existing_checksum, + "CopyObject_to_itself_by_replacing_the_checksum": CopyObject_to_itself_by_replacing_the_checksum, + "CopyObject_with_special_characters": CopyObject_with_special_characters, + "CopyObject_success": CopyObject_success, + "CopyObject_incorrect_source_bucket_expected_owner": CopyObject_incorrect_source_bucket_expected_owner, + "PutObjectTagging_non_existing_object": PutObjectTagging_non_existing_object, + "PutObjectTagging_long_tags": PutObjectTagging_long_tags, + "PutObjectTagging_duplicate_keys": PutObjectTagging_duplicate_keys, + "PutObjectTagging_tag_count_limit": PutObjectTagging_tag_count_limit, + "PutObjectTagging_invalid_tags": PutObjectTagging_invalid_tags, + "PutObjectTagging_success": PutObjectTagging_success, + "GetObjectTagging_non_existing_object": GetObjectTagging_non_existing_object, + "GetObjectTagging_unset_tags": GetObjectTagging_unset_tags, + "GetObjectTagging_invalid_parent": GetObjectTagging_invalid_parent, + "GetObjectTagging_success": GetObjectTagging_success, + "DeleteObjectTagging_non_existing_object": DeleteObjectTagging_non_existing_object, + "DeleteObjectTagging_success_status": DeleteObjectTagging_success_status, + "DeleteObjectTagging_success": DeleteObjectTagging_success, + "DeleteObjectTagging_expected_bucket_owner": DeleteObjectTagging_expected_bucket_owner, + "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, + "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, + "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, + "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, + "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, + "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, + "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, + "CreateMultipartUpload_with_object_lock_invalid_retention": CreateMultipartUpload_with_object_lock_invalid_retention, + "CreateMultipartUpload_past_retain_until_date": CreateMultipartUpload_past_retain_until_date, + "CreateMultipartUpload_invalid_legal_hold": CreateMultipartUpload_invalid_legal_hold, + "CreateMultipartUpload_invalid_object_lock_mode": CreateMultipartUpload_invalid_object_lock_mode, + "CreateMultipartUpload_object_acl_not_supported": CreateMultipartUpload_object_acl_not_supported, + "CreateMultipartUpload_invalid_checksum_algorithm": CreateMultipartUpload_invalid_checksum_algorithm, + "CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type": CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type, + "CreateMultipartUpload_type_algo_mismatch": CreateMultipartUpload_type_algo_mismatch, + "CreateMultipartUpload_invalid_checksum_type": CreateMultipartUpload_invalid_checksum_type, + "CreateMultipartUpload_valid_algo_type": CreateMultipartUpload_valid_algo_type, + "CreateMultipartUpload_success": CreateMultipartUpload_success, + "UploadPart_non_existing_bucket": UploadPart_non_existing_bucket, + "UploadPart_invalid_part_number": UploadPart_invalid_part_number, + "UploadPart_non_existing_key": UploadPart_non_existing_key, + "UploadPart_non_existing_mp_upload": UploadPart_non_existing_mp_upload, + "UploadPart_multiple_checksum_headers": UploadPart_multiple_checksum_headers, + "UploadPart_invalid_checksum_header": UploadPart_invalid_checksum_header, + "UploadPart_checksum_header_and_algo_mismatch": UploadPart_checksum_header_and_algo_mismatch, + "UploadPart_checksum_algorithm_mistmatch_on_initialization": UploadPart_checksum_algorithm_mistmatch_on_initialization, + "UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value": UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value, + "UploadPart_incorrect_checksums": UploadPart_incorrect_checksums, + "UploadPart_no_checksum_with_full_object_checksum_type": UploadPart_no_checksum_with_full_object_checksum_type, + "UploadPart_no_checksum_with_composite_checksum_type": UploadPart_no_checksum_with_composite_checksum_type, + "UploadPart_with_checksums_success": UploadPart_with_checksums_success, + "UploadPart_success": UploadPart_success, + "UploadPart_etag_quoting_consistency": UploadPart_etag_quoting_consistency, + "UploadPart_data_integrity_etag": UploadPart_data_integrity_etag, + "UploadPartCopy_non_existing_bucket": UploadPartCopy_non_existing_bucket, + "UploadPartCopy_incorrect_uploadId": UploadPartCopy_incorrect_uploadId, + "UploadPartCopy_incorrect_object_key": UploadPartCopy_incorrect_object_key, + "UploadPartCopy_invalid_part_number": UploadPartCopy_invalid_part_number, + "UploadPartCopy_invalid_copy_source": UploadPartCopy_invalid_copy_source, + "UploadPartCopy_non_existing_source_bucket": UploadPartCopy_non_existing_source_bucket, + "UploadPartCopy_non_existing_source_object_key": UploadPartCopy_non_existing_source_object_key, + "UploadPartCopy_success": UploadPartCopy_success, + "UploadPartCopy_by_range_invalid_ranges": UploadPartCopy_by_range_invalid_ranges, + "UploadPartCopy_exceeding_copy_source_range": UploadPartCopy_exceeding_copy_source_range, + "UploadPartCopy_greater_range_than_obj_size": UploadPartCopy_greater_range_than_obj_size, + "UploadPartCopy_by_range_success": UploadPartCopy_by_range_success, + "UploadPartCopy_conditional_reads": UploadPartCopy_conditional_reads, + "UploadPartCopy_incorrect_source_bucket_expected_owner": UploadPartCopy_incorrect_source_bucket_expected_owner, + "UploadPartCopy_should_copy_the_checksum": UploadPartCopy_should_copy_the_checksum, + "UploadPartCopy_should_not_copy_the_checksum": UploadPartCopy_should_not_copy_the_checksum, + "UploadPartCopy_should_calculate_the_checksum": UploadPartCopy_should_calculate_the_checksum, + "UploadPartCopy_data_integrity_etag": UploadPartCopy_data_integrity_etag, + "ListParts_incorrect_uploadId": ListParts_incorrect_uploadId, + "ListParts_incorrect_object_key": ListParts_incorrect_object_key, + "ListParts_invalid_max_parts": ListParts_invalid_max_parts, + "ListParts_invalid_part_number_marker": ListParts_invalid_part_number_marker, + "ListParts_default_max_parts": ListParts_default_max_parts, + "ListParts_truncated": ListParts_truncated, + "ListParts_with_checksums": ListParts_with_checksums, + "ListParts_null_checksums": ListParts_null_checksums, + "ListParts_success": ListParts_success, + "ListMultipartUploads_non_existing_bucket": ListMultipartUploads_non_existing_bucket, + "ListMultipartUploads_empty_result": ListMultipartUploads_empty_result, + "ListMultipartUploads_invalid_max_uploads": ListMultipartUploads_invalid_max_uploads, + "ListMultipartUploads_max_uploads": ListMultipartUploads_max_uploads, + "ListMultipartUploads_exceeding_max_uploads": ListMultipartUploads_exceeding_max_uploads, + "ListMultipartUploads_ignore_upload_id_marker": ListMultipartUploads_ignore_upload_id_marker, + "ListMultipartUploads_invalid_uploadId_marker": ListMultipartUploads_invalid_uploadId_marker, + "ListMultipartUploads_keyMarker_not_from_list": ListMultipartUploads_keyMarker_not_from_list, + "ListMultipartUploads_delimiter_truncated": ListMultipartUploads_delimiter_truncated, + "ListMultipartUploads_prefix": ListMultipartUploads_prefix, + "ListMultipartUploads_both_delimiter_and_prefix": ListMultipartUploads_both_delimiter_and_prefix, + "ListMultipartUploads_with_checksums": ListMultipartUploads_with_checksums, + "AbortMultipartUpload_non_existing_bucket": AbortMultipartUpload_non_existing_bucket, + "AbortMultipartUpload_incorrect_uploadId": AbortMultipartUpload_incorrect_uploadId, + "AbortMultipartUpload_incorrect_object_key": AbortMultipartUpload_incorrect_object_key, + "AbortMultipartUpload_success": AbortMultipartUpload_success, + "AbortMultipartUpload_success_status_code": AbortMultipartUpload_success_status_code, + "AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time, + "CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket, + "CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number, + "CompleteMultipartUpload_default_content_type": CompleteMultipartUpload_default_content_type, + "CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag, + "CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size, + "CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts, + "CompleteMultipartUpload_missing_part_fields": CompleteMultipartUpload_missing_part_fields, + "CompleteMultipartUpload_incorrect_part_number": CompleteMultipartUpload_incorrect_part_number, + "CompleteMultipartUpload_incorrect_parts_order": CompleteMultipartUpload_incorrect_parts_order, + "CompleteMultipartUpload_mpu_object_size": CompleteMultipartUpload_mpu_object_size, + "CompleteMultipartUpload_conditional_writes": CompleteMultipartUpload_conditional_writes, + "CompleteMultipartUpload_with_metadata": CompleteMultipartUpload_with_metadata, + "CompleteMultipartUpload_invalid_checksum_type": CompleteMultipartUpload_invalid_checksum_type, + "CompleteMultipartUpload_invalid_checksum_part": CompleteMultipartUpload_invalid_checksum_part, + "CompleteMultipartUpload_multiple_checksum_part": CompleteMultipartUpload_multiple_checksum_part, + "CompleteMultipartUpload_incorrect_checksum_part": CompleteMultipartUpload_incorrect_checksum_part, + "CompleteMultipartUpload_different_checksum_part": CompleteMultipartUpload_different_checksum_part, + "CompleteMultipartUpload_missing_part_checksum": CompleteMultipartUpload_missing_part_checksum, + "CompleteMultipartUpload_multiple_final_checksums": CompleteMultipartUpload_multiple_final_checksums, + "CompleteMultipartUpload_invalid_final_checksums": CompleteMultipartUpload_invalid_final_checksums, + "CompleteMultipartUpload_incorrect_final_checksums": CompleteMultipartUpload_incorrect_final_checksums, + "CompleteMultipartUpload_should_calculate_the_final_checksum_full_object": CompleteMultipartUpload_should_calculate_the_final_checksum_full_object, + "CompleteMultipartUpload_should_verify_the_final_checksum": CompleteMultipartUpload_should_verify_the_final_checksum, + "CompleteMultipartUpload_should_verify_final_composite_checksum": CompleteMultipartUpload_should_verify_final_composite_checksum, + "CompleteMultipartUpload_invalid_final_composite_checksum": CompleteMultipartUpload_invalid_final_composite_checksum, + "CompleteMultipartUpload_checksum_type_mismatch": CompleteMultipartUpload_checksum_type_mismatch, + "CompleteMultipartUpload_should_ignore_the_final_checksum": CompleteMultipartUpload_should_ignore_the_final_checksum, + "CompleteMultipartUpload_should_succeed_without_final_checksum_type": CompleteMultipartUpload_should_succeed_without_final_checksum_type, + "CompleteMultipartUpload_success": CompleteMultipartUpload_success, + "CompleteMultipartUpload_data_integrity_etag": CompleteMultipartUpload_data_integrity_etag, + "CompleteMultipartUpload_already_completed": CompleteMultipartUpload_already_completed, + "CompleteMultipartUpload_racey_success": CompleteMultipartUpload_racey_success, + "CompleteMultipartUpload_racey_data_integrity": CompleteMultipartUpload_racey_data_integrity, + "PutBucketAcl_non_existing_bucket": PutBucketAcl_non_existing_bucket, + "PutBucketAcl_disabled": PutBucketAcl_disabled, + "PutBucketAcl_none_of_the_options_specified": PutBucketAcl_none_of_the_options_specified, + "PutBucketAcl_invalid_canned_acl": PutBucketAcl_invalid_canned_acl, + "PutBucketAcl_invalid_acl_canned_and_acp": PutBucketAcl_invalid_acl_canned_and_acp, + "PutBucketAcl_invalid_acl_canned_and_grants": PutBucketAcl_invalid_acl_canned_and_grants, + "PutBucketAcl_invalid_acl_acp_and_grants": PutBucketAcl_invalid_acl_acp_and_grants, + "PutBucketAcl_invalid_owner": PutBucketAcl_invalid_owner, + "PutBucketAcl_invalid_owner_not_in_body": PutBucketAcl_invalid_owner_not_in_body, + "PutBucketAcl_invalid_empty_owner_id_in_body": PutBucketAcl_invalid_empty_owner_id_in_body, + "PutBucketAcl_invalid_permission_in_body": PutBucketAcl_invalid_permission_in_body, + "PutBucketAcl_invalid_grantee_type_in_body": PutBucketAcl_invalid_grantee_type_in_body, + "PutBucketAcl_empty_grantee_ID_in_body": PutBucketAcl_empty_grantee_ID_in_body, + "PutBucketAcl_success_access_denied": PutBucketAcl_success_access_denied, + "PutBucketAcl_success_grants": PutBucketAcl_success_grants, + "PutBucketAcl_success_canned_acl": PutBucketAcl_success_canned_acl, + "PutBucketAcl_success_acp": PutBucketAcl_success_acp, + "GetBucketAcl_non_existing_bucket": GetBucketAcl_non_existing_bucket, + "GetBucketAcl_translation_canned_public_read": GetBucketAcl_translation_canned_public_read, + "GetBucketAcl_translation_canned_public_read_write": GetBucketAcl_translation_canned_public_read_write, + "GetBucketAcl_translation_canned_private": GetBucketAcl_translation_canned_private, + "GetBucketAcl_access_denied": GetBucketAcl_access_denied, + "GetBucketAcl_success": GetBucketAcl_success, + "PutBucketPolicy_non_existing_bucket": PutBucketPolicy_non_existing_bucket, + "PutBucketPolicy_invalid_json": PutBucketPolicy_invalid_json, + "PutBucketPolicy_statement_not_provided": PutBucketPolicy_statement_not_provided, + "PutBucketPolicy_empty_statement": PutBucketPolicy_empty_statement, + "PutBucketPolicy_invalid_effect": PutBucketPolicy_invalid_effect, + "PutBucketPolicy_invalid_action": PutBucketPolicy_invalid_action, + "PutBucketPolicy_empty_principals_string": PutBucketPolicy_empty_principals_string, + "PutBucketPolicy_empty_principals_array": PutBucketPolicy_empty_principals_array, + "PutBucketPolicy_principals_aws_struct_empty_string": PutBucketPolicy_principals_aws_struct_empty_string, + "PutBucketPolicy_principals_aws_struct_empty_string_slice": PutBucketPolicy_principals_aws_struct_empty_string_slice, + "PutBucketPolicy_principals_incorrect_wildcard_usage": PutBucketPolicy_principals_incorrect_wildcard_usage, + "PutBucketPolicy_non_existing_principals": PutBucketPolicy_non_existing_principals, + "PutBucketPolicy_empty_resources_string": PutBucketPolicy_empty_resources_string, + "PutBucketPolicy_empty_resources_array": PutBucketPolicy_empty_resources_array, + "PutBucketPolicy_invalid_resource_prefix": PutBucketPolicy_invalid_resource_prefix, + "PutBucketPolicy_invalid_resource_with_starting_slash": PutBucketPolicy_invalid_resource_with_starting_slash, + "PutBucketPolicy_duplicate_resource": PutBucketPolicy_duplicate_resource, + "PutBucketPolicy_incorrect_bucket_name": PutBucketPolicy_incorrect_bucket_name, + "PutBucketPolicy_action_resource_mismatch": PutBucketPolicy_action_resource_mismatch, + "PutBucketPolicy_explicit_deny": PutBucketPolicy_explicit_deny, + "PutBucketPolicy_multi_wildcard_resource": PutBucketPolicy_multi_wildcard_resource, + "PutBucketPolicy_any_char_match": PutBucketPolicy_any_char_match, + "PutBucketPolicy_version": PutBucketPolicy_version, + "PutBucketPolicy_success": PutBucketPolicy_success, + "PutBucketPolicy_status": PutBucketPolicy_status, + "GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket, + "GetBucketPolicy_not_set": GetBucketPolicy_not_set, + "GetBucketPolicy_success": GetBucketPolicy_success, + "GetBucketPolicyStatus_non_existing_bucket": GetBucketPolicyStatus_non_existing_bucket, + "GetBucketPolicyStatus_no_such_bucket_policy": GetBucketPolicyStatus_no_such_bucket_policy, + "GetBucketPolicyStatus_success": GetBucketPolicyStatus_success, + "DeleteBucketPolicy_non_existing_bucket": DeleteBucketPolicy_non_existing_bucket, + "DeleteBucketPolicy_remove_before_setting": DeleteBucketPolicy_remove_before_setting, + "DeleteBucketPolicy_success": DeleteBucketPolicy_success, + "PutBucketCors_non_existing_bucket": PutBucketCors_non_existing_bucket, + "PutBucketCors_empty_cors_rules": PutBucketCors_empty_cors_rules, + "PutBucketCors_invalid_allowed_origins": PutBucketCors_invalid_allowed_origins, + "PutBucketCors_invalid_method": PutBucketCors_invalid_method, + "PutBucketCors_invalid_header": PutBucketCors_invalid_header, + "PutBucketCors_md5": PutBucketCors_md5, + "GetBucketCors_non_existing_bucket": GetBucketCors_non_existing_bucket, + "GetBucketCors_no_such_bucket_cors": GetBucketCors_no_such_bucket_cors, + "GetBucketCors_success": GetBucketCors_success, + "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, + "DeleteBucketCors_success": DeleteBucketCors_success, + "PutBucketCors_success": PutBucketCors_success, + "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, + "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, + "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, + "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, + "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, + "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, + "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, + "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, + "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, + "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, + "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, + "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code, + "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code, + "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large, + "PutBucketWebsite_success": PutBucketWebsite_success, + "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, + "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, + "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, + "GetBucketWebsite_success": GetBucketWebsite_success, + "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, + "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, + "DeleteBucketWebsite_success": DeleteBucketWebsite_success, + "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, + "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, + "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, + "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, + "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, + "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, + "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, + "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, + "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, + "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, + "WebsiteHosting_object_redirect_location": WebsiteHosting_object_redirect_location, + "WebsiteHosting_index_document": WebsiteHosting_index_document, + "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, + "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, + "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers, + "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted, + "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden, + "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin, + "WebsiteHosting_url_encoded_object_key": WebsiteHosting_url_encoded_object_key, + "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, + "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, + "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, + "PreflightOPTIONS_invalid_request_headers": PreflightOPTIONS_invalid_request_headers, + "PreflightOPTIONS_unset_bucket_cors": PreflightOPTIONS_unset_bucket_cors, + "PreflightOPTIONS_access_forbidden": PreflightOPTIONS_access_forbidden, + "PreflightOPTIONS_access_granted": PreflightOPTIONS_access_granted, + "CORSMiddleware_invalid_method": CORSMiddleware_invalid_method, + "CORSMiddleware_invalid_headers": CORSMiddleware_invalid_headers, + "CORSMiddleware_access_forbidden": CORSMiddleware_access_forbidden, + "CORSMiddleware_access_granted": CORSMiddleware_access_granted, + "PutObjectLockConfiguration_non_existing_bucket": PutObjectLockConfiguration_non_existing_bucket, + "PutObjectLockConfiguration_empty_request_body": PutObjectLockConfiguration_empty_request_body, + "PutObjectLockConfiguration_malformed_body": PutObjectLockConfiguration_malformed_body, + "PutObjectLockConfiguration_not_enabled_on_bucket_creation": PutObjectLockConfiguration_not_enabled_on_bucket_creation, + "PutObjectLockConfiguration_invalid_status": PutObjectLockConfiguration_invalid_status, + "PutObjectLockConfiguration_invalid_mode": PutObjectLockConfiguration_invalid_mode, + "PutObjectLockConfiguration_both_years_and_days": PutObjectLockConfiguration_both_years_and_days, + "PutObjectLockConfiguration_invalid_years_days": PutObjectLockConfiguration_invalid_years_days, + "PutObjectLockConfiguration_success": PutObjectLockConfiguration_success, + "GetObjectLockConfiguration_non_existing_bucket": GetObjectLockConfiguration_non_existing_bucket, + "GetObjectLockConfiguration_unset_config": GetObjectLockConfiguration_unset_config, + "GetObjectLockConfiguration_success": GetObjectLockConfiguration_success, + "PutObjectRetention_non_existing_bucket": PutObjectRetention_non_existing_bucket, + "PutObjectRetention_non_existing_object": PutObjectRetention_non_existing_object, + "PutObjectRetention_unset_bucket_object_lock_config": PutObjectRetention_unset_bucket_object_lock_config, + "PutObjectRetention_expired_retain_until_date": PutObjectRetention_expired_retain_until_date, + "PutObjectRetention_invalid_mode": PutObjectRetention_invalid_mode, + "PutObjectRetention_overwrite_compliance_mode": PutObjectRetention_overwrite_compliance_mode, + "PutObjectRetention_overwrite_compliance_with_compliance": PutObjectRetention_overwrite_compliance_with_compliance, + "PutObjectRetention_overwrite_governance_with_governance": PutObjectRetention_overwrite_governance_with_governance, + "PutObjectRetention_overwrite_governance_without_bypass_specified": PutObjectRetention_overwrite_governance_without_bypass_specified, + "PutObjectRetention_overwrite_governance_with_permission": PutObjectRetention_overwrite_governance_with_permission, + "PutObjectRetention_success": PutObjectRetention_success, + "GetObjectRetention_non_existing_bucket": GetObjectRetention_non_existing_bucket, + "GetObjectRetention_non_existing_object": GetObjectRetention_non_existing_object, + "GetObjectRetention_disabled_lock": GetObjectRetention_disabled_lock, + "GetObjectRetention_unset_config": GetObjectRetention_unset_config, + "GetObjectRetention_success": GetObjectRetention_success, + "PutObjectLegalHold_non_existing_bucket": PutObjectLegalHold_non_existing_bucket, + "PutObjectLegalHold_non_existing_object": PutObjectLegalHold_non_existing_object, + "PutObjectLegalHold_invalid_body": PutObjectLegalHold_invalid_body, + "PutObjectLegalHold_invalid_status": PutObjectLegalHold_invalid_status, + "PutObjectLegalHold_unset_bucket_object_lock_config": PutObjectLegalHold_unset_bucket_object_lock_config, + "PutObjectLegalHold_success": PutObjectLegalHold_success, + "GetObjectLegalHold_non_existing_bucket": GetObjectLegalHold_non_existing_bucket, + "GetObjectLegalHold_non_existing_object": GetObjectLegalHold_non_existing_object, + "GetObjectLegalHold_disabled_lock": GetObjectLegalHold_disabled_lock, + "GetObjectLegalHold_unset_config": GetObjectLegalHold_unset_config, + "GetObjectLegalHold_success": GetObjectLegalHold_success, + "PutBucketAnalyticsConfiguration_not_implemented": PutBucketAnalyticsConfiguration_not_implemented, + "GetBucketAnalyticsConfiguration_not_implemented": GetBucketAnalyticsConfiguration_not_implemented, + "ListBucketAnalyticsConfiguration_not_implemented": ListBucketAnalyticsConfiguration_not_implemented, + "DeleteBucketAnalyticsConfiguration_not_implemented": DeleteBucketAnalyticsConfiguration_not_implemented, + "PutBucketEncryption_not_implemented": PutBucketEncryption_not_implemented, + "GetBucketEncryption_not_implemented": GetBucketEncryption_not_implemented, + "DeleteBucketEncryption_not_implemented": DeleteBucketEncryption_not_implemented, + "PutBucketIntelligentTieringConfiguration_not_implemented": PutBucketIntelligentTieringConfiguration_not_implemented, + "GetBucketIntelligentTieringConfiguration_not_implemented": GetBucketIntelligentTieringConfiguration_not_implemented, + "ListBucketIntelligentTieringConfiguration_not_implemented": ListBucketIntelligentTieringConfiguration_not_implemented, + "DeleteBucketIntelligentTieringConfiguration_not_implemented": DeleteBucketIntelligentTieringConfiguration_not_implemented, + "PutBucketInventoryConfiguration_not_implemented": PutBucketInventoryConfiguration_not_implemented, + "GetBucketInventoryConfiguration_not_implemented": GetBucketInventoryConfiguration_not_implemented, + "ListBucketInventoryConfiguration_not_implemented": ListBucketInventoryConfiguration_not_implemented, + "DeleteBucketInventoryConfiguration_not_implemented": DeleteBucketInventoryConfiguration_not_implemented, + "PutBucketLifecycleConfiguration_not_implemented": PutBucketLifecycleConfiguration_not_implemented, + "GetBucketLifecycleConfiguration_not_implemented": GetBucketLifecycleConfiguration_not_implemented, + "DeleteBucketLifecycle_not_implemented": DeleteBucketLifecycle_not_implemented, + "PutBucketLogging_not_implemented": PutBucketLogging_not_implemented, + "GetBucketLogging_not_implemented": GetBucketLogging_not_implemented, + "PutBucketRequestPayment_not_implemented": PutBucketRequestPayment_not_implemented, + "GetBucketRequestPayment_not_implemented": GetBucketRequestPayment_not_implemented, + "PutBucketMetricsConfiguration_not_implemented": PutBucketMetricsConfiguration_not_implemented, + "GetBucketMetricsConfiguration_not_implemented": GetBucketMetricsConfiguration_not_implemented, + "ListBucketMetricsConfigurations_not_implemented": ListBucketMetricsConfigurations_not_implemented, + "DeleteBucketMetricsConfiguration_not_implemented": DeleteBucketMetricsConfiguration_not_implemented, + "PutBucketReplication_not_implemented": PutBucketReplication_not_implemented, + "GetBucketReplication_not_implemented": GetBucketReplication_not_implemented, + "DeleteBucketReplication_not_implemented": DeleteBucketReplication_not_implemented, + "PutPublicAccessBlock_not_implemented": PutPublicAccessBlock_not_implemented, + "GetPublicAccessBlock_not_implemented": GetPublicAccessBlock_not_implemented, + "DeletePublicAccessBlock_not_implemented": DeletePublicAccessBlock_not_implemented, + "PutBucketNotificationConfiguratio_not_implemented": PutBucketNotificationConfiguratio_not_implemented, + "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, + "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, + "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, + "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, + "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, + "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, + "WORMProtection_bucket_object_lock_configuration_governance_mode": WORMProtection_bucket_object_lock_configuration_governance_mode, + "WORMProtection_bucket_object_lock_governance_bypass_delete": WORMProtection_bucket_object_lock_governance_bypass_delete, + "WORMProtection_bucket_object_lock_governance_bypass_delete_multiple": WORMProtection_bucket_object_lock_governance_bypass_delete_multiple, + "WORMProtection_object_lock_retention_compliance_locked": WORMProtection_object_lock_retention_compliance_locked, + "WORMProtection_object_lock_retention_governance_locked": WORMProtection_object_lock_retention_governance_locked, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_copy": WORMProtection_object_lock_retention_governance_bypass_overwrite_copy, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_mp": WORMProtection_object_lock_retention_governance_bypass_overwrite_mp, + "WORMProtection_unable_to_overwrite_locked_object_put": WORMProtection_unable_to_overwrite_locked_object_put, + "WORMProtection_unable_to_overwrite_locked_object_copy": WORMProtection_unable_to_overwrite_locked_object_copy, + "WORMProtection_unable_to_overwrite_locked_object_mp": WORMProtection_unable_to_overwrite_locked_object_mp, + "WORMProtection_object_lock_retention_governance_bypass_delete": WORMProtection_object_lock_retention_governance_bypass_delete, + "WORMProtection_object_lock_retention_governance_bypass_delete_mul": WORMProtection_object_lock_retention_governance_bypass_delete_mul, + "WORMProtection_object_lock_legal_hold_locked": WORMProtection_object_lock_legal_hold_locked, + "WORMProtection_root_bypass_governance_retention_delete_object": WORMProtection_root_bypass_governance_retention_delete_object, + "PutObject_overwrite_dir_obj": PutObject_overwrite_dir_obj, + "PutObject_overwrite_file_obj": PutObject_overwrite_file_obj, + "PutObject_overwrite_file_obj_with_nested_obj": PutObject_overwrite_file_obj_with_nested_obj, + "PutObject_dir_obj_with_data": PutObject_dir_obj_with_data, + "PutObject_with_slashes": PutObject_with_slashes, + "PutObject_race_with_delete": PutObject_race_with_delete, + "CreateMultipartUpload_dir_obj": CreateMultipartUpload_dir_obj, + "IAM_user_access_denied": IAM_user_access_denied, + "IAM_userplus_access_denied": IAM_userplus_access_denied, + "IAM_userplus_CreateBucket": IAM_userplus_CreateBucket, + "IAM_admin_ChangeBucketOwner": IAM_admin_ChangeBucketOwner, + "IAM_ChangeBucketOwner_back_to_root": IAM_ChangeBucketOwner_back_to_root, + "IAM_ListBuckets": IAM_ListBuckets, + "IAM_CreateBucket_empty_owner_header": IAM_CreateBucket_empty_owner_header, + "IAM_CreateBucket_non_existing_user": IAM_CreateBucket_non_existing_user, + "IAM_CreateBucket_success": IAM_CreateBucket_success, + "AccessControl_default_ACL_user_access_denied": AccessControl_default_ACL_user_access_denied, + "AccessControl_default_ACL_userplus_access_denied": AccessControl_default_ACL_userplus_access_denied, + "AccessControl_default_ACL_admin_successful_access": AccessControl_default_ACL_admin_successful_access, + "AccessControl_bucket_resource_single_action": AccessControl_bucket_resource_single_action, + "AccessControl_bucket_resource_all_action": AccessControl_bucket_resource_all_action, + "AccessControl_single_object_resource_actions": AccessControl_single_object_resource_actions, + "AccessControl_multi_statement_policy": AccessControl_multi_statement_policy, + "AccessControl_bucket_ownership_to_user": AccessControl_bucket_ownership_to_user, + "AccessControl_root_PutBucketAcl": AccessControl_root_PutBucketAcl, + "AccessControl_user_PutBucketAcl_with_policy_access": AccessControl_user_PutBucketAcl_with_policy_access, + "AccessControl_copy_object_with_starting_slash_for_user": AccessControl_copy_object_with_starting_slash_for_user, + "AccessControl_PutObject_with_tagging_policy": AccessControl_PutObject_with_tagging_policy, + "AccessControl_PutObject_with_legal_hold_policy": AccessControl_PutObject_with_legal_hold_policy, + "AccessControl_PutObject_with_retention_policy": AccessControl_PutObject_with_retention_policy, + "AccessControl_CreateMultipartUpload_with_tagging_policy": AccessControl_CreateMultipartUpload_with_tagging_policy, + "AccessControl_CreateMultipartUpload_with_legal_hold_policy": AccessControl_CreateMultipartUpload_with_legal_hold_policy, + "AccessControl_CreateMultipartUpload_with_retention_policy": AccessControl_CreateMultipartUpload_with_retention_policy, + "AccessControl_CopyObject_with_tagging_policy": AccessControl_CopyObject_with_tagging_policy, + "AccessControl_CopyObject_with_legal_hold_policy": AccessControl_CopyObject_with_legal_hold_policy, + "AccessControl_CopyObject_with_retention_policy": AccessControl_CopyObject_with_retention_policy, + "AccessControl_policy_normalizes_object_key_for_get_put_delete": AccessControl_policy_normalizes_object_key_for_get_put_delete, + "PublicBucket_default_private_bucket": PublicBucket_default_private_bucket, + "PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy, + "PublicBucket_public_object_policy": PublicBucket_public_object_policy, + "PublicBucket_public_acl": PublicBucket_public_acl, + "PublicBucket_policy_deny_overrides_public_acl": PublicBucket_policy_deny_overrides_public_acl, + "PublicBucket_signed_streaming_payload": PublicBucket_signed_streaming_payload, + "PublicBucket_incorrect_sha256_hash": PublicBucket_incorrect_sha256_hash, + "PutBucketVersioning_non_existing_bucket": PutBucketVersioning_non_existing_bucket, + "PutBucketVersioning_invalid_status": PutBucketVersioning_invalid_status, + "PutBucketVersioning_success_enabled": PutBucketVersioning_success_enabled, + "PutBucketVersioning_success_suspended": PutBucketVersioning_success_suspended, + "GetBucketVersioning_non_existing_bucket": GetBucketVersioning_non_existing_bucket, + "GetBucketVersioning_empty_response": GetBucketVersioning_empty_response, + "GetBucketVersioning_success": GetBucketVersioning_success, + "Versioning_DeleteBucket_not_empty": Versioning_DeleteBucket_not_empty, + "Versioning_PutObject_suspended_null_versionId_obj": Versioning_PutObject_suspended_null_versionId_obj, + "Versioning_PutObject_null_versionId_obj": Versioning_PutObject_null_versionId_obj, + "Versioning_PutObject_overwrite_null_versionId_obj": Versioning_PutObject_overwrite_null_versionId_obj, + "Versioning_PutObject_success": Versioning_PutObject_success, + "Versioning_CopyObject_invalid_versionId": Versioning_CopyObject_invalid_versionId, + "Versioning_CopyObject_encoded_versionid_separator_invalid_versionId": Versioning_CopyObject_encoded_versionid_separator_invalid_versionId, + "Versioning_CopyObject_success": Versioning_CopyObject_success, + "Versioning_CopyObject_non_existing_version_id": Versioning_CopyObject_non_existing_version_id, + "Versioning_CopyObject_from_an_object_version": Versioning_CopyObject_from_an_object_version, + "Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars, + "Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId, + "Versioning_HeadObject_non_existing_object_version": Versioning_HeadObject_non_existing_object_version, + "Versioning_HeadObject_invalid_parent": Versioning_HeadObject_invalid_parent, + "Versioning_HeadObject_success": Versioning_HeadObject_success, + "Versioning_HeadObject_without_versionId": Versioning_HeadObject_without_versionId, + "Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker, + "Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId, + "Versioning_GetObject_non_existing_object_version": Versioning_GetObject_non_existing_object_version, + "Versioning_GetObject_success": Versioning_GetObject_success, + "Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId, + "Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker, + "Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj, + "Versioning_PutObjectTagging_invalid_versionId": Versioning_PutObjectTagging_invalid_versionId, + "Versioning_PutObjectTagging_non_existing_object_version": Versioning_PutObjectTagging_non_existing_object_version, + "Versioning_PutGetDeleteObjectTagging_delete_marker": Versioning_PutGetDeleteObjectTagging_delete_marker, + "Versioning_GetObjectTagging_invalid_versionId": Versioning_GetObjectTagging_invalid_versionId, + "Versioning_GetObjectTagging_non_existing_object_version": Versioning_GetObjectTagging_non_existing_object_version, + "Versioning_DeleteObjectTagging_invalid_versionId": Versioning_DeleteObjectTagging_invalid_versionId, + "Versioning_DeleteObjectTagging_non_existing_object_version": Versioning_DeleteObjectTagging_non_existing_object_version, + "Versioning_PutGetDeleteObjectTagging_success": Versioning_PutGetDeleteObjectTagging_success, + "Versioning_GetObjectAttributes_invalid_versionId": Versioning_GetObjectAttributes_invalid_versionId, + "Versioning_GetObjectAttributes_object_version": Versioning_GetObjectAttributes_object_version, + "Versioning_GetObjectAttributes_delete_marker": Versioning_GetObjectAttributes_delete_marker, + "Versioning_DeleteObject_invalid_versionId": Versioning_DeleteObject_invalid_versionId, + "Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version, + "Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object, + "Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker, + "Versioning_Delete_null_versionId_object": Versioning_Delete_null_versionId_object, + "Versioning_DeleteObject_nested_dir_object": Versioning_DeleteObject_nested_dir_object, + "Versioning_DeleteObject_non_existing_objects": Versioning_DeleteObject_non_existing_objects, + "Versioning_DeleteObject_suspended": Versioning_DeleteObject_suspended, + "Versioning_DeleteObjects_success": Versioning_DeleteObjects_success, + "Versioning_DeleteObjects_delete_deleteMarkers": Versioning_DeleteObjects_delete_deleteMarkers, + "ListObjectVersions_non_existing_bucket": ListObjectVersions_non_existing_bucket, + "ListObjectVersions_negative_max_keys": ListObjectVersions_negative_max_keys, + "ListObjectVersions_list_single_object_versions": ListObjectVersions_list_single_object_versions, + "ListObjectVersions_list_multiple_object_versions": ListObjectVersions_list_multiple_object_versions, + "ListObjectVersions_multiple_object_versions_truncated": ListObjectVersions_multiple_object_versions_truncated, + "ListObjectVersions_with_delete_markers": ListObjectVersions_with_delete_markers, + "ListObjectVersions_containing_null_versionId_obj": ListObjectVersions_containing_null_versionId_obj, + "ListObjectVersions_single_null_versionId_object": ListObjectVersions_single_null_versionId_object, + "ListObjectVersions_checksum": ListObjectVersions_checksum, + "Versioning_Multipart_Upload_success": Versioning_Multipart_Upload_success, + "Versioning_Multipart_Upload_overwrite_an_object": Versioning_Multipart_Upload_overwrite_an_object, + "Versioning_UploadPartCopy_invalid_versionId": Versioning_UploadPartCopy_invalid_versionId, + "Versioning_UploadPartCopy_encoded_versionid_separator_invalid_versionId": Versioning_UploadPartCopy_encoded_versionid_separator_invalid_versionId, + "Versioning_UploadPartCopy_non_existing_versionId": Versioning_UploadPartCopy_non_existing_versionId, + "Versioning_UploadPartCopy_from_an_object_version": Versioning_UploadPartCopy_from_an_object_version, + "Versioning_object_lock_not_enabled_on_bucket_creation": Versioning_object_lock_not_enabled_on_bucket_creation, + "Versioning_Enable_object_lock": Versioning_Enable_object_lock, + "Versioning_status_switch_to_suspended_with_object_lock": Versioning_status_switch_to_suspended_with_object_lock, + "Versioning_PutObjectRetention_invalid_versionId": Versioning_PutObjectRetention_invalid_versionId, + "Versioning_PutObjectRetention_non_existing_object_version": Versioning_PutObjectRetention_non_existing_object_version, + "Versioning_GetObjectRetention_invalid_versionId": Versioning_GetObjectRetention_invalid_versionId, + "Versioning_GetObjectRetention_non_existing_object_version": Versioning_GetObjectRetention_non_existing_object_version, + "Versioning_Put_GetObjectRetention_delete_marker": Versioning_Put_GetObjectRetention_delete_marker, + "Versioning_Put_GetObjectRetention_success": Versioning_Put_GetObjectRetention_success, + "Versioning_PutObjectLegalHold_invalid_versionId": Versioning_PutObjectLegalHold_invalid_versionId, + "Versioning_PutObjectLegalHold_non_existing_object_version": Versioning_PutObjectLegalHold_non_existing_object_version, + "Versioning_GetObjectLegalHold_invalid_versionId": Versioning_GetObjectLegalHold_invalid_versionId, + "Versioning_GetObjectLegalHold_non_existing_object_version": Versioning_GetObjectLegalHold_non_existing_object_version, + "Versioning_PutGetObjectLegalHold_delete_marker": Versioning_PutGetObjectLegalHold_delete_marker, + "Versioning_Put_GetObjectLegalHold_success": Versioning_Put_GetObjectLegalHold_success, + "Versioning_WORM_obj_version_locked_with_legal_hold": Versioning_WORM_obj_version_locked_with_legal_hold, + "Versioning_WORM_obj_version_locked_with_governance_retention": Versioning_WORM_obj_version_locked_with_governance_retention, + "Versioning_WORM_obj_version_locked_with_compliance_retention": Versioning_WORM_obj_version_locked_with_compliance_retention, + "Versioning_WORM_delete_marker_locked_object_legal_hold": Versioning_WORM_delete_marker_locked_object_legal_hold, + "Versioning_WORM_delete_marker_locked_object_governance_retention": Versioning_WORM_delete_marker_locked_object_governance_retention, + "Versioning_WORM_delete_marker_locked_object_compliance_retention": Versioning_WORM_delete_marker_locked_object_compliance_retention, + "Versioning_WORM_PutObject_overwrite_locked_object": Versioning_WORM_PutObject_overwrite_locked_object, + "Versioning_WORM_CopyObject_overwrite_locked_object": Versioning_WORM_CopyObject_overwrite_locked_object, + "Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object": Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object, + "Versioning_WORM_remove_delete_marker_under_bucket_default_retention": Versioning_WORM_remove_delete_marker_under_bucket_default_retention, + "Versioning_AccessControl_GetObjectVersion": Versioning_AccessControl_GetObjectVersion, + "Versioning_AccessControl_HeadObjectVersion": Versioning_AccessControl_HeadObjectVersion, + "Versioning_AccessControl_object_tagging_policy": Versioning_AccessControl_object_tagging_policy, + "Versioning_AccessControl_DeleteObject_policy": Versioning_AccessControl_DeleteObject_policy, + "Versioning_AccessControl_GetObjectAttributes_policy": Versioning_AccessControl_GetObjectAttributes_policy, + "Versioning_concurrent_upload_object": Versioning_concurrent_upload_object, + "RouterPutPartNumberWithoutUploadId": RouterPutPartNumberWithoutUploadId, + "RouterPostRoot": RouterPostRoot, + "RouterPostObjectWithoutQuery": RouterPostObjectWithoutQuery, + "RouterPUTObjectOnlyUploadId": RouterPUTObjectOnlyUploadId, + "RouterGetUploadsWithKey": RouterGetUploadsWithKey, + "RouterCopySourceNotAllowed": RouterCopySourceNotAllowed, + "RouterListVersionsWithKey": RouterListVersionsWithKey, + "UnsignedStreaminPayloadTrailer_malformed_trailer": UnsignedStreaminPayloadTrailer_malformed_trailer, + "UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length": UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length, + "UnsignedStreamingPayloadTrailer_invalid_trailing_checksum": UnsignedStreamingPayloadTrailer_invalid_trailing_checksum, + "UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum": UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum, + "UnsignedStreamingPayloadTrailer_multiple_checksum_headers": UnsignedStreamingPayloadTrailer_multiple_checksum_headers, + "UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch": UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch, + "UnsignedStreamingPayloadTrailer_incomplete_body": UnsignedStreamingPayloadTrailer_incomplete_body, + "UnsignedStreamingPayloadTrailer_invalid_chunk_size": UnsignedStreamingPayloadTrailer_invalid_chunk_size, + "UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch": UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch, + "UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme": UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme, + "UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers": UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers, + "UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer": UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer, + "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum, + "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object, + "UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch": UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch, + "UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer": UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer, + "UnsignedStreamingPayloadTrailer_not_allowed": UnsignedStreamingPayloadTrailer_not_allowed, + "SignedStreamingPayload_invalid_encoding": SignedStreamingPayload_invalid_encoding, + "SignedStreamingPayload_invalid_chunk_size": SignedStreamingPayload_invalid_chunk_size, + "SignedStreamingPayload_decoded_content_length_mismatch": SignedStreamingPayload_decoded_content_length_mismatch, + "SignedStreamingPayloadTrailer_malformed_trailer": SignedStreamingPayloadTrailer_malformed_trailer, + "SignedStreamingPayloadTrailer_incomplete_body": SignedStreamingPayloadTrailer_incomplete_body, + "SignedStreamingPayloadTrailer_missing_x_amz_trailer_header": SignedStreamingPayloadTrailer_missing_x_amz_trailer_header, + "SignedStreamingPayloadTrailer_invalid_checksum": SignedStreamingPayloadTrailer_invalid_checksum, + "SignedStreamingPayloadTrailer_bad_digest": SignedStreamingPayloadTrailer_bad_digest, + "SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success, + "NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl, + "NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl, + "Server_large_http_header": Server_large_http_header, + "PostObject_invalid_content_type": PostObject_invalid_content_type, + "PostObject_missing_boundary": PostObject_missing_boundary, + "PostObject_partial_auth_fields": PostObject_partial_auth_fields, + "PostObject_invalid_algorithm": PostObject_invalid_algorithm, + "PostObject_invalid_date": PostObject_invalid_date, + "PostObject_invalid_credential_format": PostObject_invalid_credential_format, + "PostObject_incorrect_region": PostObject_incorrect_region, + "PostObject_non_existing_access_key": PostObject_non_existing_access_key, + "PostObject_signature_mismatch": PostObject_signature_mismatch, + "PostObject_expired_due_to_date": PostObject_expired_due_to_date, + "PostObject_access_denied": PostObject_access_denied, + "PostObject_invalid_object_names": PostObject_invalid_object_names, + "PostObject_policy_access_control": PostObject_policy_access_control, + "PostObject_policy_expired": PostObject_policy_expired, + "PostObject_invalid_policy_document": PostObject_invalid_policy_document, + "PostObject_policy_condition_key_mismatch": PostObject_policy_condition_key_mismatch, + "PostObject_policy_extra_field": PostObject_policy_extra_field, + "PostObject_policy_missing_bucket_condition": PostObject_policy_missing_bucket_condition, + "PostObject_policy_content_length_too_large": PostObject_policy_content_length_too_large, + "PostObject_policy_content_length_too_small": PostObject_policy_content_length_too_small, + "PostObject_success": PostObject_success, + "PostObject_success_status_200": PostObject_success_status_200, + "PostObject_success_status_201": PostObject_success_status_201, + "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, + "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, + "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, + "PostObject_invalid_tagging": PostObject_invalid_tagging, + "PostObject_success_with_tagging": PostObject_success_with_tagging, + "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, + "PostObject_invalid_checksum_algorithm": PostObject_invalid_checksum_algorithm, + "PostObject_multiple_checksum_headers": PostObject_multiple_checksum_headers, + "PostObject_checksums_success": PostObject_checksums_success, + "PostObject_success_double_dash_boundary": PostObject_success_double_dash_boundary, + "IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn, + "IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short, + "IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration, + "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action": IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action, + "IAMAssumeRoleWithWebIdentity_malformed_token": IAMAssumeRoleWithWebIdentity_malformed_token, + "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max": IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max, + "IAMAssumeRoleWithWebIdentity_nonexistent_role": IAMAssumeRoleWithWebIdentity_nonexistent_role, + "IAMAssumeRoleWithWebIdentity_no_matching_principal": IAMAssumeRoleWithWebIdentity_no_matching_principal, + "IAMAssumeRoleWithWebIdentity_no_issuer_match": IAMAssumeRoleWithWebIdentity_no_issuer_match, + "IAMAssumeRoleWithWebIdentity_condition_failed": IAMAssumeRoleWithWebIdentity_condition_failed, + "IAMAssumeRoleWithWebIdentity_explicit_deny": IAMAssumeRoleWithWebIdentity_explicit_deny, + "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list": IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list, + "IAMAssumeRoleWithWebIdentity_empty_client_id_list": IAMAssumeRoleWithWebIdentity_empty_client_id_list, + "IAMAssumeRoleWithWebIdentity_idp_communication_error": IAMAssumeRoleWithWebIdentity_idp_communication_error, + "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch": IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch, + "IAMAssumeRoleWithWebIdentity_policy_arns_rejected": IAMAssumeRoleWithWebIdentity_policy_arns_rejected, + "IAMAssumeRoleWithWebIdentity_provider_id_rejected": IAMAssumeRoleWithWebIdentity_provider_id_rejected, + "IAMAssumeRoleWithWebIdentity_session_policy_too_large": IAMAssumeRoleWithWebIdentity_session_policy_too_large, + "IAMAssumeRoleWithWebIdentity_session_policy_invalid": IAMAssumeRoleWithWebIdentity_session_policy_invalid, + "IAMAssumeRoleWithWebIdentity_oaud_condition_matches": IAMAssumeRoleWithWebIdentity_oaud_condition_matches, + "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, + "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, + "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, + "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, + "IAMGetCallerIdentity_no_auth": IAMGetCallerIdentity_no_auth, + "IAMGetCallerIdentity_wrong_version_is_invalid_action": IAMGetCallerIdentity_wrong_version_is_invalid_action, + "IAMGetCallerIdentity_incorrect_service_scope": IAMGetCallerIdentity_incorrect_service_scope, + "IAMAccessControl_ImplicitDenyNoMatchingPolicy": IAMAccessControl_ImplicitDenyNoMatchingPolicy, + "IAMAccessControl_AllowGrantsMatchingRequest": IAMAccessControl_AllowGrantsMatchingRequest, + "IAMAccessControl_NonMatchingStatementDoesNotGrant": IAMAccessControl_NonMatchingStatementDoesNotGrant, + "IAMAccessControl_ExplicitDenyOverridesAllow": IAMAccessControl_ExplicitDenyOverridesAllow, + "IAMAccessControl_MultipleStatementsEvaluatedIndependently": IAMAccessControl_MultipleStatementsEvaluatedIndependently, + "IAMAccessControl_MultipleInlinePoliciesCombinedAllow": IAMAccessControl_MultipleInlinePoliciesCombinedAllow, + "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins": IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins, + "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies": IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies, + "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow": IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow, + "IAMAccessControl_ActionMatchingVariants": IAMAccessControl_ActionMatchingVariants, + "IAMAccessControl_ActionAllowOneDenyAnotherByOmission": IAMAccessControl_ActionAllowOneDenyAnotherByOmission, + "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow": IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow, + "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded": IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded, + "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded": IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded, + "IAMAccessControl_ResourceMatchingVariants": IAMAccessControl_ResourceMatchingVariants, + "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction": IAMAccessControl_ResourceOneAllowedOneDeniedSameAction, + "IAMAccessControl_ResourceWildcardRequiredForListAction": IAMAccessControl_ResourceWildcardRequiredForListAction, + "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow": IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_NotResourceExcludesTarget": IAMAccessControl_NotResourceExcludesTarget, + "IAMAccessControl_NotResourceMultipleExcludedResources": IAMAccessControl_NotResourceMultipleExcludedResources, + "IAMAccessControl_NotResourceWildcardExclusion": IAMAccessControl_NotResourceWildcardExclusion, + "IAMAccessControl_ConditionStringOperators": IAMAccessControl_ConditionStringOperators, + "IAMAccessControl_ConditionStringMultipleExpectedValuesOR": IAMAccessControl_ConditionStringMultipleExpectedValuesOR, + "IAMAccessControl_ConditionArnOperators": IAMAccessControl_ConditionArnOperators, + "IAMAccessControl_ConditionIpAddressRealSourceIp": IAMAccessControl_ConditionIpAddressRealSourceIp, + "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow": IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_ConditionMultipleContextKeysANDed": IAMAccessControl_ConditionMultipleContextKeysANDed, + "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply": IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply, + "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins": IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins, + "IAMAccessControl_ConditionOneFailedConditionVoidsStatement": IAMAccessControl_ConditionOneFailedConditionVoidsStatement, + "IAMAccessControl_ConditionNullPrincipalTag": IAMAccessControl_ConditionNullPrincipalTag, + "IAMAccessControl_ConditionIfExistsPrincipalTag": IAMAccessControl_ConditionIfExistsPrincipalTag, + "IAMAccessControl_ConditionResourceTagOnTarget": IAMAccessControl_ConditionResourceTagOnTarget, + "IAMAccessControl_ConditionRequestTagOnCreateUser": IAMAccessControl_ConditionRequestTagOnCreateUser, + "IAMAccessControl_ConditionCurrentTimeBroadWindow": IAMAccessControl_ConditionCurrentTimeBroadWindow, + "IAMAccessControl_ConditionNumericOperators": IAMAccessControl_ConditionNumericOperators, + "IAMAccessControl_ConditionDateOperators": IAMAccessControl_ConditionDateOperators, + "IAMAccessControl_ConditionBoolOperator": IAMAccessControl_ConditionBoolOperator, + "IAMAccessControl_ConditionNullOperatorClaim": IAMAccessControl_ConditionNullOperatorClaim, + "IAMAccessControl_ConditionBinaryEqualsOperator": IAMAccessControl_ConditionBinaryEqualsOperator, + "IAMAccessControl_ConditionForAnyValueOperator": IAMAccessControl_ConditionForAnyValueOperator, + "IAMAccessControl_ConditionForAllValuesOperator": IAMAccessControl_ConditionForAllValuesOperator, + "IAMAccessControl_ConditionIfExistsTrustClaim": IAMAccessControl_ConditionIfExistsTrustClaim, + "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust": IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust, + "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed": IAMAccessControl_TrustPolicyFederatedExactMatchAllowed, + "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied": IAMAccessControl_TrustPolicyFederatedWrongProviderDenied, + "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny": IAMAccessControl_TrustPolicyFederatedArrayMatchesAny, + "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored": IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored, + "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed": IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed, + "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied": IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied, + "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed": IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed, + "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied": IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied, + "IAMAccessControl_TrustPolicyAudienceCorrectAllowed": IAMAccessControl_TrustPolicyAudienceCorrectAllowed, + "IAMAccessControl_TrustPolicyAudienceIncorrectDenied": IAMAccessControl_TrustPolicyAudienceIncorrectDenied, + "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed": IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed, + "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch": IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch, + "IAMAccessControl_TrustPolicyExplicitDenyStatement": IAMAccessControl_TrustPolicyExplicitDenyStatement, + "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants": IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants, + "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied": IAMAccessControl_TrustPolicyMissingRequiredClaimDenied, + "IAMAccessControl_UserInlinePolicyWorkflow": IAMAccessControl_UserInlinePolicyWorkflow, + "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath": IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath, + "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision": IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision, + "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy": IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy, + "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer": IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer, + "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck": IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck, } } diff --git a/tests/integration/iam_access_control.go b/tests/integration/iam_access_control.go new file mode 100644 index 00000000..1c634ca1 --- /dev/null +++ b/tests/integration/iam_access_control.go @@ -0,0 +1,2843 @@ +// 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 + +// This file tests authorization (allow/deny) decisions for the standalone +// IAM/STS service: identity-based inline policies (user and role), role +// trust policies, and condition evaluation across both. It deliberately does +// not test policy-document validation, malformed input, or other API +// surface already covered by iam_put_user_policy.go/iam_create_role.go/etc. +// +// Session/session-policy scope: AssumeRoleWithWebIdentity is the only action +// that mints a session in this codebase, and a real successful call requires +// the server to fetch a real JWKS from the token's issuer and verify a real +// cryptographic signature. The SSRF guard in iamutil's OIDC fetch path +// (isDisallowedFetchTarget) unconditionally rejects loopback, private +// (RFC1918), and link-local addresses as fetch targets — so no JWKS server +// this test process stands up on the same machine can ever be reachable, +// and a real successful AssumeRoleWithWebIdentity is unreachable from this +// suite by design. Every test below that needs to observe a trust-policy +// "Allowed" decision instead uses the same technique the rest of this +// package's AssumeRoleWithWebIdentity tests already use (see +// IAMAssumeRoleWithWebIdentity_oaud_condition_matches in +// iam_assume_role_with_web_identity.go): point the provider at a loopback +// URL and observe that evaluation reaches the network-dependent signature +// step (InvalidIdentityTokenIDPCommunicationError) rather than being +// rejected earlier by trust evaluation itself (AccessDenied or the +// claims-stage InvalidIdentityToken). Reaching that step is only possible +// once Principal, Condition, and audience matching have all already +// succeeded, so it's a reliable, deterministic proxy for "Allowed" — but it +// means this suite cannot exercise anything that requires an actual minted +// session (session-policy intersection, a live session calling further IAM +// actions). + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/url" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "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" +) + +// Every ARN the gateway issues is scoped to this single fixed account. +const testAccountID = "000000000000" + +const ( + actGetUser = "iam:GetUser" + actListUsers = "iam:ListUsers" + actListUserPolicies = "iam:ListUserPolicies" + actGetUserPolicy = "iam:GetUserPolicy" + actDeleteUserPolicy = "iam:DeleteUserPolicy" + actPutUserPolicy = "iam:PutUserPolicy" + actCreateUser = "iam:CreateUser" + actGetRole = "iam:GetRole" + actListRolePolicies = "iam:ListRolePolicies" +) + +// defaultTestAudience is the OIDC ClientIDList/token-audience pair used by +// every trust-policy test below that isn't specifically exercising audience +// matching itself +var defaultTestAudience = []string{"client1"} + +// IAMAccessControl_ImplicitDenyNoMatchingPolicy verifies a caller with no +// policies at all is denied by default (no Allow ever exists to grant +// anything). +func IAMAccessControl_ImplicitDenyNoMatchingPolicy(s *S3Conf) error { + testName := "IAMAccessControl_ImplicitDenyNoMatchingPolicy" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", nil) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_AllowGrantsMatchingRequest verifies a single matching +// Allow statement grants the request, and that the response actually +// reflects the target resource (not just a nil error) — proving the call +// was genuinely authorized and executed, not accidentally short-circuited. +func IAMAccessControl_AllowGrantsMatchingRequest(s *S3Conf) error { + testName := "IAMAccessControl_AllowGrantsMatchingRequest" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + out, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil { + return err + } + if out == nil || out.User == nil || aws.ToString(out.User.UserName) != targetName { + return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, out) + } + return nil + }) +} + +// IAMAccessControl_NonMatchingStatementDoesNotGrant verifies a policy whose +// only statement covers a *different* action does not grant the tested +// action — a non-matching statement contributes nothing, it isn't a +// fallback Allow. +func IAMAccessControl_NonMatchingStatementDoesNotGrant(s *S3Conf) error { + testName := "IAMAccessControl_NonMatchingStatementDoesNotGrant" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actListRolePolicies, Resource: "*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ExplicitDenyOverridesAllow verifies an explicit Deny +// always wins over a matching Allow, regardless of statement order or +// whether the Deny is in the same policy document or a separate one. +func IAMAccessControl_ExplicitDenyOverridesAllow(s *S3Conf) error { + testName := "IAMAccessControl_ExplicitDenyOverridesAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + allow := accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn} + deny := accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn} + + cases := []struct { + name string + policies map[string]string + }{ + {"deny after allow, same document", map[string]string{"p": policyDoc(allow, deny)}}, + {"deny before allow, same document", map[string]string{"p": policyDoc(deny, allow)}}, + {"allow and deny in separate documents", map[string]string{"allow": policyDoc(allow), "deny": policyDoc(deny)}}, + } + for _, tc := range cases { + if err := func() error { + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", tc.policies) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_MultipleStatementsEvaluatedIndependently verifies two +// statements in one policy document, covering two different actions, are +// each evaluated on their own terms: both grant their own action, and +// neither grants the other's. +func IAMAccessControl_MultipleStatementsEvaluatedIndependently(s *S3Conf) error { + testName := "IAMAccessControl_MultipleStatementsEvaluatedIndependently" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Sid: "AllowGet", Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Sid: "AllowListPolicies", Effect: "Allow", Action: actListUserPolicies, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actGetUser, targetArn, err) != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil { + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + } + // Neither statement covers DeleteUserPolicy. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err) + }) +} + +// IAMAccessControl_MultipleInlinePoliciesCombinedAllow verifies two separate +// inline policies attached to the same user are combined: a statement in +// either one is enough to grant its action. +func IAMAccessControl_MultipleInlinePoliciesCombinedAllow(s *S3Conf) error { + testName := "IAMAccessControl_MultipleInlinePoliciesCombinedAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policies := map[string]string{ + "policy-a": policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}), + "policy-b": policyDoc(accessStatement{Effect: "Allow", Action: actListUserPolicies, Resource: targetArn}), + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + }) +} + +// IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins verifies a Deny in +// one inline policy overrides an Allow in a *different* inline policy on the +// same user — combination is not "most permissive wins", explicit Deny is +// global across every attached policy. +func IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins(s *S3Conf) error { + testName := "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policies := map[string]string{ + "allow-everything": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"}), + "deny-get-user": policyDoc(accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn}), + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies) + if err != nil { + return err + } + defer cleanupCaller() + + // The broad Allow still grants an unrelated action... + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + } + // ...but the specific Deny still wins for the action it names. + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies verifies an +// Allow statement present in a policy but not covering the tested +// action/resource contributes nothing — the request is still implicitly +// denied, not accidentally granted just because *some* Allow exists +// somewhere in the document. +func IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies(s *S3Conf) error { + testName := "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + otherName, _, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/" + otherName}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow verifies +// a Deny statement that doesn't cover the tested action/resource simply +// doesn't apply — it does not somehow block an unrelated Allow elsewhere in +// the same policy. +func IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow(s *S3Conf) error { + testName := "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ActionMatchingVariants covers exact, wildcard, array, and +// case-insensitive Action matching, all against the same target resource so +// only the Action dimension varies row to row. +func IAMAccessControl_ActionMatchingVariants(s *S3Conf) error { + testName := "IAMAccessControl_ActionMatchingVariants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + action any + wantAllowed bool + }{ + {"exact action match", "iam:GetUser", true}, + {"service wildcard iam:*", "iam:*", true}, + {"operation prefix wildcard iam:Get*", "iam:Get*", true}, + {"suffix wildcard iam:*User", "iam:*User", true}, + {"single-char ? wildcard", "iam:GetUse?", true}, + {"action present in an array", []string{"iam:ListUsers", "iam:GetUser"}, true}, + {"case-insensitive policy action", "IAM:GETUSER", true}, + {"nonmatching action", "iam:PutUserPolicy", false}, + {"nonmatching prefix wildcard", "iam:List*", false}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: tc.action, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ActionAllowOneDenyAnotherByOmission verifies a policy +// granting exactly one action grants only that action — a sibling action +// against the very same resource is still denied. +func IAMAccessControl_ActionAllowOneDenyAnotherByOmission(s *S3Conf) error { + testName := "IAMAccessControl_ActionAllowOneDenyAnotherByOmission" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actListUserPolicies, targetArn, err) + }) +} + +// IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow verifies an +// explicit Deny for one specific action carves it out of an otherwise +// all-encompassing wildcard Allow, without affecting any other action the +// wildcard still covers. +func IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow(s *S3Conf) error { + testName := "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: "iam:*", Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err) + }) +} + +// IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded verifies an +// Allow+NotAction statement grants every action *except* the ones listed — +// the excluded action is denied, a nonexcluded one is allowed. +func IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded(s *S3Conf) error { + testName := "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", NotAction: []string{actListUsers, actDeleteUserPolicy}, Resource: "*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + // GetUser is not in the NotAction list, so it's covered by the Allow. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + // ListUsers is excluded via NotAction, so the statement doesn't cover it. + _, err = listIAMUsers(caller.client, &iam.ListUsersInput{}) + return wantDenied(caller.arn, actListUsers, "*", err) + }) +} + +// IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded verifies the +// interaction between an Action-based Allow and a NotAction-based Deny: a +// broad Allow grants everything, but a Deny+NotAction statement denies every +// action *except* the one named — net effect, only that one action remains +// allowed. +func IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded(s *S3Conf) error { + testName := "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"}, + accessStatement{Effect: "Deny", NotAction: actGetUser, Resource: "*"}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + // GetUser is excluded from the Deny's NotAction coverage, so only the + // Allow applies to it. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + // Every other action is covered by the Deny (it's not GetUser). + _, err = listIAMUsers(caller.client, &iam.ListUsersInput{}) + return wantDenied(caller.arn, actListUsers, "*", err) + }) +} + +// IAMAccessControl_ResourceMatchingVariants covers exact, wildcard, and +// array Resource matching for both a user and a role target. +func IAMAccessControl_ResourceMatchingVariants(s *S3Conf) error { + testName := "IAMAccessControl_ResourceMatchingVariants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetUserName, targetUserArn, cleanupUser, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupUser() + targetRoleName, targetRoleArn, cleanupRole, err := newTargetRole(root) + if err != nil { + return err + } + defer cleanupRole() + pathUserName, pathUserArn, cleanupPathUser, err := newTargetUserWithPath(root, "/ac-team/") + if err != nil { + return err + } + defer cleanupPathUser() + otherName, _, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + run := func(name, action, resourcePattern, wantResource string, call func(client *iam.Client) error) error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: action, Resource: resourcePattern}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + if err := wantAllowed(caller.arn, action, wantResource, call(caller.client)); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("exact user ARN", actGetUser, targetUserArn, targetUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)}) + return err + }); err != nil { + return err + } + if err := run("exact role ARN", actGetRole, targetRoleArn, targetRoleArn, func(c *iam.Client) error { + _, err := getIAMRole(c, targetRoleName) + return err + }); err != nil { + return err + } + if err := run("wildcard resource ARN", actGetUser, "*", targetUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)}) + return err + }); err != nil { + return err + } + if err := run("resource path wildcard", actGetUser, "arn:aws:iam::"+testAccountID+":user/ac-team/*", pathUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(pathUserName)}) + return err + }); err != nil { + return err + } + + // Multiple resources in an array: both named ARNs are granted, a third + // (equally valid) resource is not. + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: []string{targetUserArn, pathUserArn}}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return fmt.Errorf("resource array: %w", err) + } + defer cleanupCaller() + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetUserName)}); wantAllowed(caller.arn, actGetUser, targetUserArn, err) != nil { + return fmt.Errorf("resource array, first entry: %w", wantAllowed(caller.arn, actGetUser, targetUserArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(pathUserName)}); wantAllowed(caller.arn, actGetUser, pathUserArn, err) != nil { + return fmt.Errorf("resource array, second entry: %w", wantAllowed(caller.arn, actGetUser, pathUserArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, "(not in array)", err) != nil { + return fmt.Errorf("resource array, nonmatching entry: %w", wantDenied(caller.arn, actGetUser, "(not in array)", err)) + } + + // Nonmatching resource: exact grant to one user does not cover another. + exactPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetUserArn}) + exactCaller, cleanupExact, err := newAccessControlCaller(root, s, "", map[string]string{"p": exactPolicy}) + if err != nil { + return fmt.Errorf("nonmatching resource denied: %w", err) + } + defer cleanupExact() + _, err = getIAMUser(exactCaller.client, &iam.GetUserInput{UserName: aws.String(otherName)}) + if err := wantDenied(exactCaller.arn, actGetUser, targetUserArn, err); err != nil { + return fmt.Errorf("nonmatching resource denied: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ResourceOneAllowedOneDeniedSameAction verifies a +// resource-scoped Allow grants the same action against its named resource +// but denies it against an equally-valid, unrelated resource. +func IAMAccessControl_ResourceOneAllowedOneDeniedSameAction(s *S3Conf) error { + testName := "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction" + return iamActionHandler(s, testName, func(root *iam.Client) error { + allowedName, allowedArn, cleanupAllowed, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupAllowed() + deniedName, deniedArn, cleanupDenied, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupDenied() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: allowedArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(allowedName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, allowedArn, err) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(deniedName)}) + return wantDenied(caller.arn, actGetUser, deniedArn, err) + }) +} + +// IAMAccessControl_ResourceWildcardRequiredForListAction verifies a +// List-type action (whose only valid resource-level scope is "*", per +// resourceForAction's classification) is denied by a resource-scoped grant +// naming a specific entity, and allowed once the grant uses "*". +func IAMAccessControl_ResourceWildcardRequiredForListAction(s *S3Conf) error { + testName := "IAMAccessControl_ResourceWildcardRequiredForListAction" + return iamActionHandler(s, testName, func(root *iam.Client) error { + _, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + scoped := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: targetArn}) + scopedCaller, cleanupScoped, err := newAccessControlCaller(root, s, "", map[string]string{"p": scoped}) + if err != nil { + return err + } + defer cleanupScoped() + _, err = listIAMUsers(scopedCaller.client, &iam.ListUsersInput{}) + if err := wantDenied(scopedCaller.arn, actListUsers, "*", err); err != nil { + return fmt.Errorf("resource-scoped grant: %w", err) + } + + wildcard := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: "*"}) + wildcardCaller, cleanupWildcard, err := newAccessControlCaller(root, s, "", map[string]string{"p": wildcard}) + if err != nil { + return err + } + defer cleanupWildcard() + _, err = listIAMUsers(wildcardCaller.client, &iam.ListUsersInput{}) + if err := wantAllowed(wildcardCaller.arn, actListUsers, "*", err); err != nil { + return fmt.Errorf("wildcard grant: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow verifies a +// Deny scoped to one specific resource carves it out of a broader +// Resource:"*" Allow, without affecting any other resource the Allow still +// covers. +func IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow(s *S3Conf) error { + testName := "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + blockedName, blockedArn, cleanupBlocked, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupBlocked() + otherName, otherArn, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: blockedArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, otherArn, err) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(blockedName)}) + return wantDenied(caller.arn, actGetUser, blockedArn, err) + }) +} + +// IAMAccessControl_NotResourceExcludesTarget verifies both directions of +// NotResource: an Allow+NotResource statement applies to every resource +// *except* the excluded one, while a Deny+NotResource statement (layered +// over a broader baseline Allow) denies every resource *except* the +// excluded one — the excluded resource's fate inverts between the two. +func IAMAccessControl_NotResourceExcludesTarget(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceExcludesTarget" + return iamActionHandler(s, testName, func(root *iam.Client) error { + user1Name, user1Arn, cleanup1, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanup1() + user2Name, user2Arn, cleanup2, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanup2() + + // Allow + NotResource[user2]: user1 allowed, user2 (excluded) denied. + allowPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: user2Arn}) + allowCaller, cleanupAllow, err := newAccessControlCaller(root, s, "", map[string]string{"p": allowPolicy}) + if err != nil { + return err + } + defer cleanupAllow() + if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantAllowed(allowCaller.arn, actGetUser, user1Arn, err) != nil { + return fmt.Errorf("Allow+NotResource, non-excluded: %w", wantAllowed(allowCaller.arn, actGetUser, user1Arn, err)) + } + if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantDenied(allowCaller.arn, actGetUser, user2Arn, err) != nil { + return fmt.Errorf("Allow+NotResource, excluded: %w", wantDenied(allowCaller.arn, actGetUser, user2Arn, err)) + } + + // Baseline Allow(*) + Deny+NotResource[user2]: user1 denied (Deny + // covers it, since it's not the excluded one), user2 allowed (Deny + // doesn't cover the excluded resource, so only the baseline Allow + // applies to it). + denyPolicy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"}, + accessStatement{Effect: "Deny", Action: actGetUser, NotResource: user2Arn}, + ) + denyCaller, cleanupDeny, err := newAccessControlCaller(root, s, "", map[string]string{"p": denyPolicy}) + if err != nil { + return err + } + defer cleanupDeny() + if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantDenied(denyCaller.arn, actGetUser, user1Arn, err) != nil { + return fmt.Errorf("Deny+NotResource, non-excluded: %w", wantDenied(denyCaller.arn, actGetUser, user1Arn, err)) + } + if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantAllowed(denyCaller.arn, actGetUser, user2Arn, err) != nil { + return fmt.Errorf("Deny+NotResource, excluded: %w", wantAllowed(denyCaller.arn, actGetUser, user2Arn, err)) + } + return nil + }) +} + +// IAMAccessControl_NotResourceMultipleExcludedResources verifies a +// NotResource array excludes every listed resource, not just the first. +func IAMAccessControl_NotResourceMultipleExcludedResources(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceMultipleExcludedResources" + return iamActionHandler(s, testName, func(root *iam.Client) error { + includedName, includedArn, cleanupIncluded, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupIncluded() + excluded1Name, excluded1Arn, cleanupExcluded1, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupExcluded1() + excluded2Name, excluded2Arn, cleanupExcluded2, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupExcluded2() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: []string{excluded1Arn, excluded2Arn}}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil { + return fmt.Errorf("non-excluded resource: %w", wantAllowed(caller.arn, actGetUser, includedArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded1Name)}); wantDenied(caller.arn, actGetUser, excluded1Arn, err) != nil { + return fmt.Errorf("first excluded resource: %w", wantDenied(caller.arn, actGetUser, excluded1Arn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded2Name)}) + if err := wantDenied(caller.arn, actGetUser, excluded2Arn, err); err != nil { + return fmt.Errorf("second excluded resource: %w", err) + } + return nil + }) +} + +// IAMAccessControl_NotResourceWildcardExclusion verifies NotResource +// supports the same wildcard glob Resource does: excluding a whole +// path-prefix pattern excludes every resource under it, not just one exact +// ARN. +func IAMAccessControl_NotResourceWildcardExclusion(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceWildcardExclusion" + return iamActionHandler(s, testName, func(root *iam.Client) error { + excludedName, excludedArn, cleanupExcluded, err := newTargetUserWithPath(root, "/ac-excluded/") + if err != nil { + return err + } + defer cleanupExcluded() + includedName, includedArn, cleanupIncluded, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupIncluded() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: "arn:aws:iam::" + testAccountID + ":user/ac-excluded/*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil { + return fmt.Errorf("outside excluded path: %w", wantAllowed(caller.arn, actGetUser, includedArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excludedName)}) + if err := wantDenied(caller.arn, actGetUser, excludedArn, err); err != nil { + return fmt.Errorf("inside excluded path: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionStringOperators covers the full String +// condition-operator family against aws:username — a key this suite fully +// controls on both sides (the caller's actual username, and the policy's +// expected value), giving every row a deterministic outcome. +func IAMAccessControl_ConditionStringOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionStringOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + callerName string + condition func(callerName string) json.RawMessage + wantAllowed bool + }{ + {"StringEquals exact match", "ac-str-alice-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringEquals", "aws:username", c) }, true}, + {"StringEquals nonmatch", "ac-str-bob-" + genRandString(6), + func(string) json.RawMessage { return cond("StringEquals", "aws:username", "someone-else") }, false}, + {"StringNotEquals matches when different", "ac-str-carol-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotEquals", "aws:username", "someone-else") }, true}, + {"StringNotEquals denies when equal", "ac-str-dave-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringNotEquals", "aws:username", c) }, false}, + {"StringEqualsIgnoreCase matches different case", "ac-str-erin-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringEqualsIgnoreCase", "aws:username", upperASCII(c)) }, true}, + {"StringNotEqualsIgnoreCase denies matching case-insensitively", "ac-str-frank-" + genRandString(6), + func(c string) json.RawMessage { + return cond("StringNotEqualsIgnoreCase", "aws:username", upperASCII(c)) + }, false}, + {"StringLike prefix wildcard", "ac-str-wild-prefix-" + genRandString(6), + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-prefix-*") }, true}, + {"StringLike suffix wildcard", "ac-str-wild-suffix-suf", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "*-suf") }, true}, + {"StringLike middle wildcard", "ac-str-wild-mid-zzz-tail", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-mid-*-tail") }, true}, + {"StringLike ? wildcard", "ac-str-wld-abc", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wld-a?c") }, true}, + {"StringLike nonmatch", "ac-str-nomatch-" + genRandString(6), + func(string) json.RawMessage { return cond("StringLike", "aws:username", "totally-different-*") }, false}, + {"StringNotLike denies matching wildcard", "ac-str-notlike-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "ac-str-notlike-*") }, false}, + {"StringNotLike allows nonmatching wildcard", "ac-str-abc-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "zzz-*") }, true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition(tc.callerName)}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, tc.callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionStringMultipleExpectedValuesOR verifies a +// StringEquals condition with an array of expected values matches if the +// actual value equals *any* of them. +func IAMAccessControl_ConditionStringMultipleExpectedValuesOR(s *S3Conf) error { + testName := "IAMAccessControl_ConditionStringMultipleExpectedValuesOR" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-str-or-" + genRandString(8) + condition := cond("StringEquals", "aws:username", []string{"nobody-1", callerName, "nobody-2"}) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionArnOperators covers the ArnEquals/ArnLike/ +// ArnNotEquals/ArnNotLike family against aws:PrincipalArn — a real, +// fully-known ARN this suite controls exactly (the caller's own Arn). +func IAMAccessControl_ConditionArnOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionArnOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-arn-" + genRandString(8) + callerArnPattern := "arn:aws:iam::" + testAccountID + ":user/" + callerName + otherArn := "arn:aws:iam::" + testAccountID + ":user/someone-else" + + cases := []struct { + name string + condition json.RawMessage + wantAllowed bool + }{ + {"ArnEquals exact match", cond("ArnEquals", "aws:PrincipalArn", callerArnPattern), true}, + {"ArnEquals nonmatch", cond("ArnEquals", "aws:PrincipalArn", otherArn), false}, + {"ArnLike wildcard match", cond("ArnLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), true}, + {"ArnNotEquals matches when different", cond("ArnNotEquals", "aws:PrincipalArn", otherArn), true}, + {"ArnNotEquals denies when equal", cond("ArnNotEquals", "aws:PrincipalArn", callerArnPattern), false}, + {"ArnNotLike denies matching wildcard", cond("ArnNotLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), false}, + {"array of expected ARNs matches any", cond("ArnEquals", "aws:PrincipalArn", []string{otherArn, callerArnPattern}), true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionIpAddressRealSourceIp covers IpAddress/ +// NotIpAddress against the *real* aws:SourceIp the gateway observes for this +// test process's own connection (see callerSourceIP), proving the +// source-IP condition context is actually wired end to end — not just that +// the operator's CIDR logic works in isolation (see +// IAMAccessControl_ConditionIpAddressOperators for the broader operator +// coverage via a fully test-controlled claim value). +func IAMAccessControl_ConditionIpAddressRealSourceIp(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIpAddressRealSourceIp" + return iamActionHandler(s, testName, func(root *iam.Client) error { + sourceIP, err := callerSourceIP(s) + if err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + condition json.RawMessage + wantAllowed bool + }{ + {"exact IP match", cond("IpAddress", "aws:SourceIp", sourceIP), true}, + {"broad CIDR match", cond("IpAddress", "aws:SourceIp", "127.0.0.0/8"), true}, + {"CIDR outside range denied", cond("IpAddress", "aws:SourceIp", "10.0.0.0/8"), false}, + {"NotIpAddress denies matching range", cond("NotIpAddress", "aws:SourceIp", "127.0.0.0/8"), false}, + {"NotIpAddress allows non-matching range", cond("NotIpAddress", "aws:SourceIp", "10.0.0.0/8"), true}, + {"multiple CIDRs, one matches (OR)", cond("IpAddress", "aws:SourceIp", []string{"10.0.0.0/8", "127.0.0.0/8"}), true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow +// verifies a Deny scoped to one IP range carves it out of a broader Allow, +// using a range guaranteed to contain this test process's real source IP. +func IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + if _, err := callerSourceIP(s); err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("IpAddress", "aws:SourceIp", "127.0.0.0/8")}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionMultipleContextKeysANDed verifies two different +// condition keys within the same Condition block are ANDed: both +// aws:username and aws:PrincipalTag/department must match for the statement +// to apply. +func IAMAccessControl_ConditionMultipleContextKeysANDed(s *S3Conf) error { + testName := "IAMAccessControl_ConditionMultipleContextKeysANDed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-and-" + genRandString(8) + condition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": callerName, "aws:PrincipalTag/department": "eng"}, + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + + // Both keys match. + matching, cleanupMatching, err := newAccessControlCallerTagged(root, s, callerName, map[string]string{"p": policy}, map[string]string{"department": "eng"}) + if err != nil { + return err + } + defer cleanupMatching() + if _, err := getIAMUser(matching.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(matching.arn, actGetUser, targetArn, err) != nil { + return fmt.Errorf("both keys match: %w", wantAllowed(matching.arn, actGetUser, targetArn, err)) + } + + // Username matches but the tag does not: one failed key voids the + // whole statement (AND, not OR, across keys). + wrongTagName := "ac-and-" + genRandString(8) + wrongTagCondition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": wrongTagName, "aws:PrincipalTag/department": "eng"}, + }) + wrongTagPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: wrongTagCondition}) + mismatched, cleanupMismatched, err := newAccessControlCallerTagged(root, s, wrongTagName, map[string]string{"p": wrongTagPolicy}, map[string]string{"department": "sales"}) + if err != nil { + return err + } + defer cleanupMismatched() + _, err = getIAMUser(mismatched.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantDenied(mismatched.arn, actGetUser, targetArn, err); err != nil { + return fmt.Errorf("one key mismatched: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply verifies +// that when an Allow's condition matches but a separate Deny statement's own +// condition does *not* match, the Deny simply doesn't apply and the Allow +// wins — a failing condition on a Deny is not the same as the Deny being +// absent, but it does mean that particular Deny never fires. +func IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply(s *S3Conf) error { + testName := "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-mixed-" + genRandString(8) + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", "not-"+callerName)}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins verifies that when +// both an Allow's and a Deny's conditions match the same request, the Deny +// still wins — condition-matching does not change explicit Deny precedence. +func IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins(s *S3Conf) error { + testName := "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-bothmatch-" + genRandString(8) + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionOneFailedConditionVoidsStatement verifies a +// statement combining two condition keys (ANDed) does not apply if either +// one fails to match — demonstrated here via aws:username (matching) AND +// aws:SourceIp (deliberately scoped to a range that excludes this test +// process's real source IP). +func IAMAccessControl_ConditionOneFailedConditionVoidsStatement(s *S3Conf) error { + testName := "IAMAccessControl_ConditionOneFailedConditionVoidsStatement" + return iamActionHandler(s, testName, func(root *iam.Client) error { + if _, err := callerSourceIP(s); err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-voided-" + genRandString(8) + condition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": callerName}, + "IpAddress": {"aws:SourceIp": "10.0.0.0/8"}, // deliberately excludes the real (127.0.0.0/8) source + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionNullPrincipalTag covers the Null operator +// against aws:PrincipalTag/, a key that's genuinely absent from +// request context for an untagged caller and present for a tagged one — +// exercising Null's "key does not exist"/"key exists" semantics against a +// real, request-driven context key rather than a synthetic one. +func IAMAccessControl_ConditionNullPrincipalTag(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNullPrincipalTag" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + run := func(name string, tags map[string]string, nullValue string, wantAllow bool) error { + condition := cond("Null", "aws:PrincipalTag/department", nullValue) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if wantAllow { + err = wantAllowed(caller.arn, actGetUser, targetArn, err) + } else { + err = wantDenied(caller.arn, actGetUser, targetArn, err) + } + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("Null true matches absent tag", nil, "true", true); err != nil { + return err + } + if err := run("Null true denies present tag", map[string]string{"department": "eng"}, "true", false); err != nil { + return err + } + if err := run("Null false matches present tag", map[string]string{"department": "eng"}, "false", true); err != nil { + return err + } + return run("Null false denies absent tag", nil, "false", false) + }) +} + +// IAMAccessControl_ConditionIfExistsPrincipalTag covers a StringEqualsIfExists +// condition against aws:PrincipalTag/: absent (vacuously allowed), +// present and matching (allowed), present and mismatched (denied). +func IAMAccessControl_ConditionIfExistsPrincipalTag(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIfExistsPrincipalTag" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + condition := cond("StringEqualsIfExists", "aws:PrincipalTag/department", "eng") + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + + run := func(name string, tags map[string]string, wantAllow bool) error { + caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if wantAllow { + err = wantAllowed(caller.arn, actGetUser, targetArn, err) + } else { + err = wantDenied(caller.arn, actGetUser, targetArn, err) + } + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("absent tag is vacuously allowed", nil, true); err != nil { + return err + } + if err := run("present matching tag allowed", map[string]string{"department": "eng"}, true); err != nil { + return err + } + return run("present mismatched tag denied", map[string]string{"department": "sales"}, false) + }) +} + +// IAMAccessControl_ConditionResourceTagOnTarget covers iam:ResourceTag/ +// aws:ResourceTag: a Condition scoping the *target* resource's own tag, +// proving resourceForAction's tag resolution is wired into Condition +// evaluation, not just the caller's own tags. +func IAMAccessControl_ConditionResourceTagOnTarget(s *S3Conf) error { + testName := "IAMAccessControl_ConditionResourceTagOnTarget" + return iamActionHandler(s, testName, func(root *iam.Client) error { + taggedName, taggedArn, cleanupTagged, err := newTargetUserTagged(root, map[string]string{"team": "payments"}) + if err != nil { + return err + } + defer cleanupTagged() + untaggedName, untaggedArn, cleanupUntagged, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupUntagged() + + condition := cond("StringEquals", "iam:ResourceTag/team", "payments") + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*", Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(taggedName)}); wantAllowed(caller.arn, actGetUser, taggedArn, err) != nil { + return fmt.Errorf("matching resource tag: %w", wantAllowed(caller.arn, actGetUser, taggedArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(untaggedName)}) + if err := wantDenied(caller.arn, actGetUser, untaggedArn, err); err != nil { + return fmt.Errorf("untagged resource: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionRequestTagOnCreateUser covers aws:RequestTag/ +// aws:TagKeys: a Condition scoping the Tags parameter of a CreateUser +// request itself, proving request-scoped (not just principal- or +// resource-scoped) context is evaluated. +func IAMAccessControl_ConditionRequestTagOnCreateUser(s *S3Conf) error { + testName := "IAMAccessControl_ConditionRequestTagOnCreateUser" + return iamActionHandler(s, testName, func(root *iam.Client) error { + condition := cond("StringEquals", "aws:RequestTag/team", "payments") + policy := policyDoc(accessStatement{ + Effect: "Allow", Action: actCreateUser, + Resource: "arn:aws:iam::" + testAccountID + ":user/ac-created-*", + Condition: condition, + }) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + allowedName := "ac-created-" + genRandString(10) + out, err := createIAMUser(caller.client, &iam.CreateUserInput{ + UserName: aws.String(allowedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("payments")}}, + }) + if err := wantAllowed(caller.arn, actCreateUser, allowedName, err); err != nil { + return fmt.Errorf("matching request tag: %w", err) + } + if out != nil { + defer deleteIAMUser(root, allowedName) + } + + deniedName := "ac-created-" + genRandString(10) + _, err = createIAMUser(caller.client, &iam.CreateUserInput{ + UserName: aws.String(deniedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("other")}}, + }) + return wantDenied(caller.arn, actCreateUser, deniedName, err) + }) +} + +// IAMAccessControl_ConditionCurrentTimeBroadWindow covers Numeric/Date +// operators against the server's own request-time keys (aws:EpochTime, +// aws:CurrentTime) — since "now" can't be injected or fixed by the test, +// this uses deliberately broad, never-flaky bounds (year 2001 through year +// 2100) rather than tight boundaries; see +// IAMAccessControl_ConditionNumericOperators/ConditionDateOperators for +// precise boundary coverage against a fully test-controlled claim value. +func IAMAccessControl_ConditionCurrentTimeBroadWindow(s *S3Conf) error { + testName := "IAMAccessControl_ConditionCurrentTimeBroadWindow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + condition := condAll(map[string]map[string]any{ + "NumericGreaterThan": {"aws:EpochTime": "1000000000"}, // ~2001 + "NumericLessThan": {"aws:EpochTime": "4102444800"}, // ~2100 + "DateGreaterThan": {"aws:CurrentTime": "2001-01-01T00:00:00Z"}, + "DateLessThan": {"aws:CurrentTime": "2100-01-01T00:00:00Z"}, + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// upperASCII uppercases a plain ASCII string (test fixture names are always +// ASCII), avoiding a dependency on strings.ToUpper's full-Unicode behavior +// for what's fundamentally a fixed test value. +func upperASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - ('a' - 'A') + } + } + return string(b) +} + +// IAMAccessControl_ConditionNumericOperators covers the full Numeric +// condition-operator family, using a custom "level" claim this suite fully +// controls, around a fixed boundary value of 5. +func IAMAccessControl_ConditionNumericOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNumericOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + numCond := func(operator string, value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond(operator, host+":level", value) } + } + cases := []federatedConditionCase{ + {"NumericEquals at boundary allowed", map[string]any{"level": 5}, numCond("NumericEquals", 5), true}, + {"NumericEquals off boundary denied", map[string]any{"level": 5}, numCond("NumericEquals", 6), false}, + {"NumericNotEquals allowed when different", map[string]any{"level": 5}, numCond("NumericNotEquals", 6), true}, + {"NumericNotEquals denied when equal", map[string]any{"level": 5}, numCond("NumericNotEquals", 5), false}, + {"NumericLessThan below boundary allowed", map[string]any{"level": 5}, numCond("NumericLessThan", 6), true}, + {"NumericLessThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericLessThan", 5), false}, + {"NumericLessThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericLessThanEquals", 5), true}, + {"NumericLessThanEquals above boundary denied", map[string]any{"level": 6}, numCond("NumericLessThanEquals", 5), false}, + {"NumericGreaterThan above boundary allowed", map[string]any{"level": 6}, numCond("NumericGreaterThan", 5), true}, + {"NumericGreaterThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThan", 5), false}, + {"NumericGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThanEquals", 5), true}, + {"NumericGreaterThanEquals below boundary denied", map[string]any{"level": 4}, numCond("NumericGreaterThanEquals", 5), false}, + {"multiple expected values matches any (OR)", map[string]any{"level": 5}, numCond("NumericEquals", []any{5, 100}), true}, + {"missing context key denies", map[string]any{}, numCond("NumericEquals", 5), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionDateOperators covers the full Date +// condition-operator family, using a custom "joined" claim around a fixed +// boundary of 2024-06-15T00:00:00Z (epoch 1718409600) — both RFC3339 and +// epoch-seconds forms are exercised since evaluateCondition accepts either +// on either side. +func IAMAccessControl_ConditionDateOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionDateOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + const boundary = "2024-06-15T00:00:00Z" + const before = "2024-01-01T00:00:00Z" + const after = "2024-12-01T00:00:00Z" + dateCond := func(operator string, value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond(operator, host+":joined", value) } + } + cases := []federatedConditionCase{ + {"DateEquals exact match", map[string]any{"joined": boundary}, dateCond("DateEquals", boundary), true}, + {"DateEquals nonmatch", map[string]any{"joined": boundary}, dateCond("DateEquals", before), false}, + {"DateEquals matches across epoch-vs-RFC3339 forms", map[string]any{"joined": "1718409600"}, dateCond("DateEquals", boundary), true}, + {"DateNotEquals allowed when different", map[string]any{"joined": boundary}, dateCond("DateNotEquals", before), true}, + {"DateNotEquals denied when equal", map[string]any{"joined": boundary}, dateCond("DateNotEquals", boundary), false}, + {"DateLessThan before boundary allowed", map[string]any{"joined": before}, dateCond("DateLessThan", boundary), true}, + {"DateLessThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThan", boundary), false}, + {"DateLessThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThanEquals", boundary), true}, + {"DateLessThanEquals after boundary denied", map[string]any{"joined": after}, dateCond("DateLessThanEquals", boundary), false}, + {"DateGreaterThan after boundary allowed", map[string]any{"joined": after}, dateCond("DateGreaterThan", boundary), true}, + {"DateGreaterThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThan", boundary), false}, + {"DateGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThanEquals", boundary), true}, + {"DateGreaterThanEquals before boundary denied", map[string]any{"joined": before}, dateCond("DateGreaterThanEquals", boundary), false}, + {"multiple expected dates matches any (OR)", map[string]any{"joined": boundary}, dateCond("DateEquals", []any{before, boundary}), true}, + {"missing date context denies", map[string]any{}, dateCond("DateGreaterThan", boundary), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionBoolOperator covers Bool: true/false claim +// values, a string-typed "true"/"false" claim (still matched, since both +// sides parse via strconv.ParseBool), and a missing key. +func IAMAccessControl_ConditionBoolOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionBoolOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + boolCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("Bool", host+":admin", value) } + } + cases := []federatedConditionCase{ + {"true claim matches Bool true", map[string]any{"admin": true}, boolCond(true), true}, + {"false claim denied against Bool true", map[string]any{"admin": false}, boolCond(true), false}, + {"false claim matches Bool false", map[string]any{"admin": false}, boolCond(false), true}, + {"string representation \"true\" matches Bool true", map[string]any{"admin": "true"}, boolCond(true), true}, + {"missing key denies", map[string]any{}, boolCond(true), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionNullOperatorClaim covers Null against a custom +// claim: key exists vs. does not, Null:true vs. Null:false, and Null +// combined (ANDed) with a separate StringEquals condition in the same +// statement. +func IAMAccessControl_ConditionNullOperatorClaim(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNullOperatorClaim" + return iamActionHandler(s, testName, func(root *iam.Client) error { + nullCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("Null", host+":nickname", value) } + } + cases := []federatedConditionCase{ + {"Null true matches when key absent", map[string]any{}, nullCond("true"), true}, + {"Null true denies when key present", map[string]any{"nickname": "bob"}, nullCond("true"), false}, + {"Null false matches when key present", map[string]any{"nickname": "bob"}, nullCond("false"), true}, + {"Null false denies when key absent", map[string]any{}, nullCond("false"), false}, + { + "Null combined with StringEquals: both satisfied allowed", + map[string]any{"nickname": "bob"}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "Null": {host + ":nickname": "false"}, + "StringEquals": {host + ":nickname": "bob"}, + }) + }, + true, + }, + { + "Null combined with StringEquals: Null satisfied but StringEquals fails denies", + map[string]any{"nickname": "bob"}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "Null": {host + ":nickname": "false"}, + "StringEquals": {host + ":nickname": "someone-else"}, + }) + }, + false, + }, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionBinaryEqualsOperator covers BinaryEquals with +// deterministic base64-encoded claim values. +func IAMAccessControl_ConditionBinaryEqualsOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionBinaryEqualsOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + const wantB64 = "aGVsbG8=" // base64("hello") + const otherB64 = "d29ybGQ=" // base64("world") + binCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("BinaryEquals", host+":cert", value) } + } + cases := []federatedConditionCase{ + {"matching base64 value allowed", map[string]any{"cert": wantB64}, binCond(wantB64), true}, + {"nonmatching base64 value denied", map[string]any{"cert": otherB64}, binCond(wantB64), false}, + {"missing key denied", map[string]any{}, binCond(wantB64), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionForAnyValueOperator covers ForAnyValue: +// StringEquals against a multi-valued "groups" claim: one request value +// matching is enough. +func IAMAccessControl_ConditionForAnyValueOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionForAnyValueOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + anyCond := func(expected any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("ForAnyValue:StringEquals", host+":groups", expected) } + } + cases := []federatedConditionCase{ + {"one request value matches", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"qa", "admin"}), true}, + {"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"dev", "qa"}), true}, + {"none match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"admin"}), false}, + {"empty request-value set never matches", map[string]any{"groups": []string{}}, anyCond([]any{"dev"}), false}, + {"missing context key denies", map[string]any{}, anyCond([]any{"dev"}), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionForAllValuesOperator covers +// ForAllValues:StringEquals against a multi-valued "groups" claim: every +// request value must match one of the expected values. +func IAMAccessControl_ConditionForAllValuesOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionForAllValuesOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + allCond := func(expected any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("ForAllValues:StringEquals", host+":groups", expected) } + } + cases := []federatedConditionCase{ + {"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev", "qa", "admin"}), true}, + {"only some request values match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev"}), false}, + {"none match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"admin"}), false}, + {"empty request-value set is vacuously true", map[string]any{"groups": []string{}}, allCond([]any{"dev"}), true}, + {"missing context key is vacuously true", map[string]any{}, allCond([]any{"dev"}), true}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionIfExistsTrustClaim covers a *IfExists operator +// against a custom claim: absent (vacuously allowed), present and matching +// (allowed), present and mismatched (denied). +func IAMAccessControl_ConditionIfExistsTrustClaim(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIfExistsTrustClaim" + return iamActionHandler(s, testName, func(root *iam.Client) error { + ifExistsCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("StringEqualsIfExists", host+":department", value) } + } + cases := []federatedConditionCase{ + {"absent key is vacuously allowed", map[string]any{}, ifExistsCond("eng"), true}, + {"present matching key allowed", map[string]any{"department": "eng"}, ifExistsCond("eng"), true}, + {"present mismatched key denied", map[string]any{"department": "sales"}, ifExistsCond("eng"), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust verifies two +// separate operator blocks in the same trust-statement Condition (a +// StringEquals on sub and a NumericGreaterThan on a custom claim) are +// ANDed: both must be satisfied. +func IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust(s *S3Conf) error { + testName := "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []federatedConditionCase{ + { + "both operator blocks satisfied allowed", + map[string]any{"sub": "user1", "level": 5}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "StringEquals": {host + ":sub": "user1"}, + "NumericGreaterThan": {host + ":level": 3}, + }) + }, + true, + }, + { + "sub matches but level condition fails denies", + map[string]any{"sub": "user1", "level": 2}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "StringEquals": {host + ":sub": "user1"}, + "NumericGreaterThan": {host + ":level": 3}, + }) + }, + false, + }, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// Principal-related authorization decisions are tested exclusively through +// role trust policies: an identity-based inline policy can never carry a +// Principal at all (PutUserPolicy/PutRolePolicy reject one outright), so +// there is nothing to test on that side. Within trust policies, only +// Principal.Federated is ever consulted at runtime — this gateway +// implements just sts:AssumeRoleWithWebIdentity, never a plain sts:AssumeRole +// or AssumeRoleWithSAML, so an "AWS" (IAM user/role/root/account) or +// "Service" principal, while accepted by write-time validation, has no +// runtime authorization meaning at all. IAMAccessControl_ +// TrustPolicyNonFederatedPrincipalsIgnored demonstrates this divergence from +// real AWS directly. NotPrincipal is likewise grammar-recognized but +// unconditionally rejected at write time on both identity and trust +// policies (Allow and Deny alike), so no valid stored policy can ever carry +// one — there is no authorization decision to test, only a validation +// rejection, which is out of this suite's scope by design. + +// IAMAccessControl_TrustPolicyFederatedExactMatchAllowed verifies a trust +// policy naming the exact registered OIDC provider ARN as its Federated +// principal allows assumption for a token issued by that provider. +func IAMAccessControl_TrustPolicyFederatedExactMatchAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyFederatedWrongProviderDenied verifies a trust +// policy federating a *real, registered* provider still denies a token +// issued by a *different* real, registered provider — an existing-but- +// mismatched principal, distinct from a dangling reference to a provider +// that was never created at all (see +// IAMAssumeRoleWithWebIdentity_no_matching_principal for that case). +func IAMAccessControl_TrustPolicyFederatedWrongProviderDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, _, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupRole() + + otherProviderURL := newLoopbackOIDCURL() + otherProviderArn, err := createTestOIDCProviderWithURL(root, otherProviderURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, otherProviderArn) + + token := mustToken(map[string]any{"iss": otherProviderURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyFederatedArrayMatchesAny verifies a Federated +// principal given as an array of provider ARNs matches a token issued by +// *either* one. +func IAMAccessControl_TrustPolicyFederatedArrayMatchesAny(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny" + return iamActionHandler(s, testName, func(root *iam.Client) error { + firstURL := newLoopbackOIDCURL() + firstArn, err := createTestOIDCProviderWithURL(root, firstURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, firstArn) + + roleArn, secondURL, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": []string{firstArn, providerArn}}, Action: "sts:AssumeRoleWithWebIdentity", + }) + }, nil) + if err != nil { + return err + } + defer cleanupRole() + + // A token from the *second* array entry (not the first) still matches. + token := mustToken(map[string]any{"iss": secondURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored documents a +// meaningful divergence from real AWS IAM: this gateway's only +// AssumeRole-family action is AssumeRoleWithWebIdentity, so +// EvaluateWebIdentityTrust only ever inspects a statement's +// Principal.Federated value — an "AWS" principal (even a wildcard "*", or a +// literal account root ARN, both of which would grant real AWS's plain +// sts:AssumeRole) or a "Service" principal is accepted by write-time +// validation but has no runtime effect: a role trusting *only* one of these +// can never actually be assumed by anyone, denied exactly as if the trust +// policy had no usable principal at all. +func IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + principal any + }{ + {"AWS wildcard principal alone", map[string]any{"AWS": "*"}}, + {"AWS root account principal alone", map[string]any{"AWS": "arn:aws:iam::" + testAccountID + ":root"}}, + {"Service principal alone", map[string]any{"Service": "sts.amazonaws.com"}}, + } + for _, tc := range cases { + if err := func() error { + roleName := "ac-nonfed-" + genRandString(12) + trust := trustDoc(trustStatement{Effect: "Allow", Principal: tc.principal, Action: "sts:AssumeRoleWithWebIdentity"}) + if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil { + return err + } + defer deleteIAMRole(root, roleName) + + roleArn := "arn:aws:iam::" + testAccountID + ":role/" + roleName + token := mustToken(map[string]any{"iss": "https://unused.example.com", "aud": "client1", "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedNoPrincipal(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed verifies a +// StringEquals condition on :sub allows a token whose subject +// matches exactly. +func IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/main"}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied is the +// StringEqualsSubjectExactAllowed companion: a different repository's +// subject is denied. +func IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/other-repo:ref:refs/heads/main"}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed verifies a +// StringLike condition on :sub with a trailing wildcard allows any +// branch under refs/heads/ — a realistic GitHub-Actions-style pattern. +func IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/feature-x"}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied is the +// StringLikeBranchWildcardAllowed companion: a pull-request-triggered +// subject (a different sub shape entirely, not matching the refs/heads/* +// pattern) is denied. +func IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:pull_request"}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceCorrectAllowed verifies a StringEquals +// condition on :aud allows a token whose (ClientIDList-valid) +// audience matches the condition's expected value. The provider's +// ClientIDList registers *two* acceptable audiences so this and +// AudienceIncorrectDenied can each present a ClientIDList-valid audience, +// isolating the Condition itself as what's actually under test (see +// newFederatedRole's doc comment). +func IAMAccessControl_TrustPolicyAudienceCorrectAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceCorrectAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", "expected-aud"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "expected-aud", "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceIncorrectDenied is the +// AudienceCorrectAllowed companion: an audience that's valid per +// ClientIDList but doesn't match the trust policy's Condition is denied. +func IAMAccessControl_TrustPolicyAudienceIncorrectDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceIncorrectDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", "expected-aud"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "other-aud", "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed verifies a +// StringEquals condition on :aud with an array of acceptable +// values matches any one of them. +func IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"aud-one", "aud-two"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", []string{"aud-one", "aud-two"}), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "aud-two", "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch verifies a +// trust statement with Conditions on both :aud and :sub +// requires both to match — either alone is not enough. +func IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + aud, sub string + wantAllowed bool + }{ + {"both match allowed", "expected-aud", "expected-sub", true}, + {"only audience matches denied", "expected-aud", "wrong-sub", false}, + {"only subject matches denied", "wrong-aud", "expected-sub", false}, + {"neither matches denied", "wrong-aud", "wrong-sub", false}, + } + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "wrong-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: condAll(map[string]map[string]any{ + "StringEquals": {host + ":aud": "expected-aud", host + ":sub": "expected-sub"}, + }), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": tc.aud, "sub": tc.sub, "exp": 9999999999}) + if tc.wantAllowed { + return wantTrustAllowed(s, roleArn, token) + } + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyExplicitDenyStatement verifies an explicit +// Deny statement scoped to one subject blocks assumption for that subject +// while a broader Allow still covers every other subject. +func IAMAccessControl_TrustPolicyExplicitDenyStatement(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyExplicitDenyStatement" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc( + trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + trustStatement{ + Effect: "Deny", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "blocked-user"), + }, + ) + }, nil) + if err != nil { + return err + } + defer cleanup() + + blockedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "blocked-user", "exp": 9999999999}) + if err := wantTrustDeniedExplicit(s, roleArn, blockedToken); err != nil { + return fmt.Errorf("blocked subject: %w", err) + } + + allowedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999}) + if err := wantTrustAllowed(s, roleArn, allowedToken); err != nil { + return fmt.Errorf("non-blocked subject: %w", err) + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants verifies a +// trust policy is evaluated statement by statement across the whole +// document: a first statement referencing an unrelated provider doesn't +// prevent a second statement (for the *actual* issuer) from granting +// assumption. +func IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + unrelatedURL := newLoopbackOIDCURL() + unrelatedArn, err := createTestOIDCProviderWithURL(root, unrelatedURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, unrelatedArn) + + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc( + trustStatement{Sid: "Unrelated", Effect: "Allow", Principal: map[string]any{"Federated": unrelatedArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + trustStatement{Sid: "Actual", Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + ) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyMissingRequiredClaimDenied verifies a +// StringEquals condition against a claim key the token simply never carries +// denies assumption — a positive (non-IfExists) operator against an absent +// key fails closed (see IAMAccessControl_ConditionIfExistsTrustClaim for +// the IfExists variant's opposite behavior on the same kind of absence). +func IAMAccessControl_TrustPolicyMissingRequiredClaimDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":employee_id", "12345"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + // The token never includes an employee_id claim at all. + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_UserInlinePolicyWorkflow exercises the full lifecycle a +// user's inline policy goes through: create two users (one caller, one +// target), attach an inline policy scoped to a condition on the caller's +// own identity, create access keys, make signed calls as the caller, +// verify the permitted action+resource succeeds, verify denial for another +// action, another user resource, a condition mismatch (a second, +// differently-named caller under the same policy shape), and an explicit +// Deny, then update the policy and verify the changed authorization takes +// effect while the explicit Deny still holds. +func IAMAccessControl_UserInlinePolicyWorkflow(s *S3Conf) error { + testName := "IAMAccessControl_UserInlinePolicyWorkflow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + otherName, otherArn, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + callerName := "ac-workflow-" + genRandString(10) + grant := func(callerUserName string) string { + return policyDoc( + accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: actGetUser, Resource: targetArn, + Condition: cond("StringEquals", "aws:username", callerUserName)}, + accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"}, + ) + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"grant": grant(callerName)}) + if err != nil { + return err + } + defer cleanupCaller() + + // Permitted action + resource succeeds, and genuinely returns the + // target's data (not just a nil error). + getOut, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil { + return fmt.Errorf("permitted action+resource: %w", err) + } + if getOut == nil || getOut.User == nil || aws.ToString(getOut.User.UserName) != targetName { + return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, getOut) + } + + // Another action against the same resource is denied. + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantDenied(caller.arn, actListUserPolicies, targetArn, err) != nil { + return fmt.Errorf("another action: %w", wantDenied(caller.arn, actListUserPolicies, targetArn, err)) + } + + // The same permitted action against a different user resource is denied. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, otherArn, err) != nil { + return fmt.Errorf("another resource: %w", wantDenied(caller.arn, actGetUser, otherArn, err)) + } + + // A condition mismatch (a caller whose own username differs from what + // the policy's Condition expects) is denied even under the identical + // policy shape. + mismatchName := "ac-workflow-" + genRandString(10) + mismatchCaller, cleanupMismatch, err := newAccessControlCaller(root, s, mismatchName, map[string]string{"grant": grant(callerName)}) + if err != nil { + return err + } + defer cleanupMismatch() + if _, err := getIAMUser(mismatchCaller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantDenied(mismatchCaller.arn, actGetUser, targetArn, err) != nil { + return fmt.Errorf("condition mismatch: %w", wantDenied(mismatchCaller.arn, actGetUser, targetArn, err)) + } + + // An explicit Deny blocks an action the broad wildcard Resource on + // that statement would otherwise apply to, regardless of what the + // named policy/resource actually is. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil { + return fmt.Errorf("explicit deny: %w", err) + } + + // Updating the policy to grant the previously-denied action takes + // effect immediately. + updated := policyDoc( + accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: []string{actGetUser, actListUserPolicies}, Resource: targetArn, + Condition: cond("StringEquals", "aws:username", callerName)}, + accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"}, + ) + if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(caller.userName), PolicyName: aws.String("grant"), PolicyDocument: aws.String(updated), + }); err != nil { + return fmt.Errorf("update policy: %w", err) + } + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil { + return fmt.Errorf("newly granted action after update: %w", wantAllowed(caller.arn, actListUserPolicies, targetArn, err)) + } + + // The explicit Deny is still in effect after the update. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil { + return fmt.Errorf("explicit deny after update: %w", err) + } + return nil + }) +} + +// IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath verifies a +// resource pattern scoped to one path prefix grants access to users under +// that path but not to a user with a different path, even with an +// otherwise-identical name prefix. +func IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath(s *S3Conf) error { + testName := "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath" + return iamActionHandler(s, testName, func(root *iam.Client) error { + inPathName, inPathArn, cleanupInPath, err := newTargetUserWithPath(root, "/ac-finance/") + if err != nil { + return err + } + defer cleanupInPath() + outOfPathName, outOfPathArn, cleanupOutOfPath, err := newTargetUserWithPath(root, "/ac-marketing/") + if err != nil { + return err + } + defer cleanupOutOfPath() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/ac-finance/*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(inPathName)}); wantAllowed(caller.arn, actGetUser, inPathArn, err) != nil { + return fmt.Errorf("in-path user: %w", wantAllowed(caller.arn, actGetUser, inPathArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(outOfPathName)}) + if err := wantDenied(caller.arn, actGetUser, outOfPathArn, err); err != nil { + return fmt.Errorf("out-of-path user: %w", err) + } + return nil + }) +} + +// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision +// demonstrates that trust-policy authorization and role-permission +// authorization are separate stages: a role's inline (permission) policy — +// absent, permissive, or deny-all — has no bearing on whether the role can +// be assumed. Every variant reaches the identical trust-evaluation outcome +// (this suite's network-stage proxy for "Allowed", per the file doc +// comment) with the trust policy held fixed. +func IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision(s *S3Conf) error { + testName := "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + rolePermission map[string]string + }{ + {"no permission policy at all", nil}, + {"broad permissive permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})}}, + {"deny-all permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Deny", Action: "iam:*", Resource: "*"})}}, + } + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, tc.rolePermission) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy is the +// converse of RolePermissionPolicyDoesNotAffectAssumptionDecision: even a +// maximally permissive role permission policy cannot compensate for a trust +// policy that doesn't authorize the caller — assumption is still denied. +func IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy(s *S3Conf) error { + testName := "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, _, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "expected-user"), + }) + }, map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})}) + if err != nil { + return err + } + defer cleanup() + + // A different subject: trust Condition fails despite the role's own + // permission policy granting everything. + token := mustToken(map[string]any{"iss": "https://unused-in-this-assertion.example.com", "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer +// verifies isolation between two independently-configured federated roles: +// a token issued for role A's provider cannot assume role B, even though it +// can (still) assume role A. +func IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer(s *S3Conf) error { + testName := "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleAArn, providerAURL, cleanupA, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupA() + + roleBArn, _, cleanupB, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupB() + + tokenForA := mustToken(map[string]any{"iss": providerAURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + + if err := wantTrustAllowed(s, roleAArn, tokenForA); err != nil { + return fmt.Errorf("token still assumes its own role: %w", err) + } + if err := wantTrustDeniedInvalidClaims(s, roleBArn, tokenForA); err != nil { + return fmt.Errorf("same token cannot assume an unrelated role: %w", err) + } + return nil + }) +} + +// IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck +// documents a meaningful divergence from real AWS's plain sts:AssumeRole: +// this gateway's only assume-role action is unauthenticated (see +// stsOpenRoute in iamapi/router.go — VerifyIAMAuth never runs for it), so +// there is no calling IAM identity and thus no identity-based-policy check +// on the assumption call itself, only the target role's trust policy. This +// is demonstrated by showing an identical trust/token pair produces an +// identical result (the same network-dependent failure this suite uses +// throughout as its proxy for reaching a genuine Allowed decision — see the +// file doc comment) whether the request is signed with the real root +// credential or with a completely arbitrary, nonexistent access key: if +// caller identity mattered here, at least one of these would fail +// differently (e.g. an unknown-access-key error) instead of both reaching +// the identical outcome. +func IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck(s *S3Conf) error { + testName := "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + + if err := wantTrustAllowed(s, roleArn, token); err != nil { + return fmt.Errorf("signed with the real root credential: %w", err) + } + + bogusCfg := *s + bogusCfg.awsID, bogusCfg.awsSecret = "AKIA"+genRandString(16), genRandString(32) + if err := wantTrustAllowed(&bogusCfg, roleArn, token); err != nil { + return fmt.Errorf("signed with an arbitrary, nonexistent access key: %w", err) + } + return nil + }) +} + +// accessControlCaller is an isolated IAM user with its own long-term access +// key, used as the authenticated caller for an identity-policy authorization +// test. +type accessControlCaller struct { + userName string + userID string + arn string + client *iam.Client +} + +// newAccessControlCaller creates an isolated IAM user (userName, or an +// auto-generated one if empty), attaches the given named inline policies +// (policyName -> document; may be nil/empty), creates one long-term access +// key, and returns an *iam.Client authenticated as that user plus a cleanup +// func that removes the key, every attached policy, and the user itself. +func newAccessControlCaller(root *iam.Client, s *S3Conf, userName string, policies map[string]string) (*accessControlCaller, func(), error) { + return newAccessControlCallerTagged(root, s, userName, policies, nil) +} + +// newAccessControlCallerTagged is newAccessControlCaller plus tags on the +// created user, for aws:PrincipalTag/Null/IfExists-style tests. +func newAccessControlCallerTagged(root *iam.Client, s *S3Conf, userName string, policies map[string]string, tags map[string]string) (*accessControlCaller, func(), error) { + if userName == "" { + userName = newIAMUserName() + } + + input := &iam.CreateUserInput{UserName: aws.String(userName)} + for k, v := range tags { + input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) + } + createOut, err := createIAMUser(root, input) + if err != nil { + return nil, nil, fmt.Errorf("create caller user: %w", err) + } + + for name, doc := range policies { + if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(userName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + deleteIAMUser(root, userName) + return nil, nil, fmt.Errorf("attach caller policy %q: %w", name, err) + } + } + + keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + if err != nil { + deleteAccessControlCaller(root, userName) + return nil, nil, fmt.Errorf("create caller access key: %w", err) + } + + caller := &accessControlCaller{ + userName: userName, + userID: aws.ToString(createOut.User.UserId), + arn: aws.ToString(createOut.User.Arn), + client: iamClientWithCreds(s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey), ""), + } + cleanup := func() { deleteAccessControlCaller(root, userName) } + return caller, cleanup, nil +} + +// deleteAccessControlCaller removes every dependency DeleteUser would +// otherwise reject (inline policies, access keys) before deleting the user +// itself. Neither of the existing deleteIAMUserAndPolicies/ +// deleteIAMUserAndAccessKeys helpers alone covers the combination +// newAccessControlCaller's fixtures always create (both policies and a +// key), so this file needs its own. +func deleteAccessControlCaller(root *iam.Client, userName string) error { + polOut, err := listIAMUserPolicies(root, &iam.ListUserPoliciesInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, name := range polOut.PolicyNames { + if err := deleteIAMUserPolicy(root, userName, name); err != nil { + return err + } + } + + keyOut, err := listIAMAccessKeys(root, &iam.ListAccessKeysInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, key := range keyOut.AccessKeyMetadata { + if err := deleteIAMAccessKey(root, userName, aws.ToString(key.AccessKeyId)); err != nil { + return err + } + } + + return deleteIAMUser(root, userName) +} + +// newTargetUser creates a plain, isolated IAM user with no policies of its +// own, to be used as the resource another caller's policy is tested +// against. +func newTargetUser(root *iam.Client) (userName, arn string, cleanup func(), err error) { + return newTargetUserWithPath(root, "") +} + +// newTargetUserWithPath is newTargetUser with an explicit Path, for +// resource-path-wildcard tests. +func newTargetUserWithPath(root *iam.Client, path string) (userName, arn string, cleanup func(), err error) { + userName = "ac-target-" + genRandString(12) + input := &iam.CreateUserInput{UserName: aws.String(userName)} + if path != "" { + input.Path = aws.String(path) + } + out, err := createIAMUser(root, input) + if err != nil { + return "", "", nil, err + } + return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil +} + +// newTargetUserTagged is newTargetUser plus tags, for +// iam:ResourceTag/aws:ResourceTag condition tests. +func newTargetUserTagged(root *iam.Client, tags map[string]string) (userName, arn string, cleanup func(), err error) { + userName = "ac-target-" + genRandString(12) + input := &iam.CreateUserInput{UserName: aws.String(userName)} + for k, v := range tags { + input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) + } + out, err := createIAMUser(root, input) + if err != nil { + return "", "", nil, err + } + return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil +} + +// newTargetRole creates a plain role (permissive default trust policy, no +// inline policies) to be used as the resource another caller's policy is +// tested against. +func newTargetRole(root *iam.Client) (roleName, arn string, cleanup func(), err error) { + roleName = "ac-target-role-" + genRandString(12) + if _, err = createIAMRole(root, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return "", "", nil, err + } + return roleName, "arn:aws:iam::" + testAccountID + ":role/" + roleName, func() { deleteIAMRole(root, roleName) }, nil +} + +// iamClientWithCreds builds an *iam.Client authenticated as the given +// access/secret/session-token triple, reusing s's endpoint/region/http +// client. S3Conf has no session-token field of its own (only +// AssumeRoleWithWebIdentity-derived credentials would ever need one, and +// this file never gets that far — see the file doc comment), so every call +// site here passes token="" — but the parameter exists so this stays +// reusable if that ever changes. +func iamClientWithCreds(s *S3Conf, access, secret, token string) *iam.Client { + cfg := s.Config() + cfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + return iam.NewFromConfig(cfg) +} + +// getIAMUser is the GetUser counterpart to the existing getIAMRole/ +// getIAMUserPolicy/getIAMRolePolicy helpers elsewhere in this package — no +// prior test file needed a generic wrapper for it. +func getIAMUser(client *iam.Client, input *iam.GetUserInput) (*iam.GetUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetUser(ctx, input) +} + +// wantAllowed reports a descriptive error if err is non-nil, identifying the +// caller, action, and resource a test expected to be authorized. +func wantAllowed(callerArn, action, resource string, err error) error { + if err != nil { + return fmt.Errorf("caller=%s action=%s resource=%s: expected ALLOW, got error: %v", callerArn, action, resource, err) + } + return nil +} + +// wantDenied asserts err is exactly the AccessDenied error VerifyIAMPolicy +// produces for callerArn/action — not merely "some error" (a wrong ARN, a +// missing parameter, or a not-found resource must not be mistaken for an +// authorization denial). +func wantDenied(callerArn, action, resource string, err error) error { + if cerr := checkIAMApiErr(err, iamerr.AccessDeniedIAMAction(callerArn, action)); cerr != nil { + return fmt.Errorf("caller=%s action=%s resource=%s: expected DENY: %w", callerArn, action, resource, cerr) + } + return nil +} + +// accessStatement is a safe, type-checked builder for one identity-policy +// statement — used instead of hand-formatted JSON strings so a test typo +// produces a Go compile error or a visibly-wrong marshaled document instead +// of a silently-malformed policy. Action/NotAction/Resource/NotResource +// accept either a bare string or a []string (both marshal the way this +// gateway's StringOrSlice unmarshals them). +type accessStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Action any `json:"Action,omitempty"` + NotAction any `json:"NotAction,omitempty"` + Resource any `json:"Resource,omitempty"` + NotResource any `json:"NotResource,omitempty"` + Condition json.RawMessage `json:"Condition,omitempty"` +} + +// policyDoc marshals statements into a complete "2012-10-17" identity-policy +// document string. Marshaling a fixed struct of strings/[]string/ +// json.RawMessage cannot fail in practice; a panic here means a test itself +// is malformed, not a runtime condition to recover from. +func policyDoc(statements ...accessStatement) string { + doc := struct { + Version string `json:"Version"` + Statement []accessStatement `json:"Statement"` + }{"2012-10-17", statements} + b, err := json.Marshal(doc) + if err != nil { + panic(fmt.Sprintf("iam_access_control: policyDoc: %v", err)) + } + return string(b) +} + +// trustStatement is accessStatement's counterpart for role trust policies: +// Principal is required (never NotPrincipal — see the file's Principal +// section for why versitygw rejects NotPrincipal unconditionally), and +// Resource/NotResource don't exist in trust-policy grammar at all. +type trustStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Principal any `json:"Principal"` + Action any `json:"Action,omitempty"` + NotAction any `json:"NotAction,omitempty"` + Condition json.RawMessage `json:"Condition,omitempty"` +} + +func trustDoc(statements ...trustStatement) string { + doc := struct { + Version string `json:"Version"` + Statement []trustStatement `json:"Statement"` + }{"2012-10-17", statements} + b, err := json.Marshal(doc) + if err != nil { + panic(fmt.Sprintf("iam_access_control: trustDoc: %v", err)) + } + return string(b) +} + +// cond builds a Condition block containing a single operator/key/value(s) +// entry, e.g. cond("StringEquals", "aws:username", "alice") or +// cond("StringEquals", "aws:username", []string{"alice", "bob"}). +func cond(operator, key string, value any) json.RawMessage { + b, err := json.Marshal(map[string]map[string]any{operator: {key: value}}) + if err != nil { + panic(fmt.Sprintf("iam_access_control: cond: %v", err)) + } + return b +} + +// condAll builds a Condition block from multiple operator blocks and/or +// multiple keys within a block, for multi-condition-semantics tests (see +// evaluateCondition's AND-across-operators/keys, OR-across-values +// semantics). +func condAll(blocks map[string]map[string]any) json.RawMessage { + b, err := json.Marshal(blocks) + if err != nil { + panic(fmt.Sprintf("iam_access_control: condAll: %v", err)) + } + return b +} + +// mustToken wraps webIdentityTokenWithClaims for call sites that pass fixed, +// well-formed claims — a marshal failure there means a test itself is +// malformed, not a runtime condition. +func mustToken(claims map[string]any) string { + tok, err := webIdentityTokenWithClaims(claims) + if err != nil { + panic(fmt.Sprintf("iam_access_control: mustToken: %v", err)) + } + return tok +} + +// newLoopbackOIDCURL returns a random loopback-IP-based OIDC provider URL. +// Every trust-policy test in this file that needs to observe an "Allowed" +// decision (see the file doc comment) federates a loopback provider so +// evaluation deterministically fails at the network-dependent signature step +// instead of hanging or attempting real internet access. A random address, +// rather than a fixed one like 127.0.0.1, keeps concurrently-running +// subtests from colliding on the same provider identity. +func newLoopbackOIDCURL() string { + return fmt.Sprintf("https://127.%d.%d.%d", 1+rand.Intn(254), 1+rand.Intn(254), 1+rand.Intn(254)) +} + +// newFederatedRole creates a fresh OIDC provider at a random loopback URL +// (see newLoopbackOIDCURL) with the given ClientIDList, then a role whose +// trust policy is buildTrust(providerArn, providerURL) — buildTrust is +// handed both so it can reference the provider as a Federated principal and +// build ":"-style Condition keys (via trimProviderScheme). +// rolePolicies (may be nil) are attached as the role's inline *permission* +// policies; several tests in this file deliberately vary these (empty, +// permissive, deny-all) while holding the trust policy fixed, to +// demonstrate that a role's permission policy has no bearing on whether it +// can be assumed — only its trust policy does (see +// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision). +func newFederatedRole(root *iam.Client, clientIDs []string, buildTrust func(providerArn, providerURL string) string, rolePolicies map[string]string) (roleArn, providerURL string, cleanup func(), err error) { + providerURL = newLoopbackOIDCURL() + out, err := createOIDCProvider(root, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", "", nil, fmt.Errorf("create provider: %w", err) + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + roleName := "ac-role-" + genRandString(12) + trust := buildTrust(providerArn, providerURL) + if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil { + deleteOIDCProvider(root, providerArn) + return "", "", nil, fmt.Errorf("create role: %w", err) + } + + for name, doc := range rolePolicies { + if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{ + RoleName: aws.String(roleName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + deleteIAMRoleAndPolicies(root, roleName) + deleteOIDCProvider(root, providerArn) + return "", "", nil, fmt.Errorf("attach role policy %q: %w", name, err) + } + } + + roleArn = "arn:aws:iam::" + testAccountID + ":role/" + roleName + cleanup = func() { + deleteIAMRoleAndPolicies(root, roleName) + deleteOIDCProvider(root, providerArn) + } + return roleArn, providerURL, cleanup, nil +} + +// wantTrustAllowed asserts that assuming roleArn with token reaches the +// network-dependent signature-verification stage — this suite's +// deterministic, black-box-observable proxy for "trust policy evaluation +// returned Allowed" (see the file doc comment). roleArn's trust policy must +// federate a loopback-URL provider (see newLoopbackOIDCURL/newFederatedRole) +// for the network step to fail deterministically instead of hanging or +// attempting real internet access. +func wantTrustAllowed(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.InvalidIdentityTokenIDPCommunicationError()) +} + +// wantTrustDeniedNoPrincipal asserts assumption fails the way it does when +// no statement's Federated principal resolves to a provider that actually +// exists (policy.NoPrincipal) — the same AccessDenied outcome AWS also uses +// for a role that doesn't exist at all, never confirming or denying which. +func wantTrustDeniedNoPrincipal(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) +} + +// wantTrustDeniedExplicit asserts assumption fails via an explicit Deny +// statement (policy.ExplicitlyDenied) — also AccessDenied, but reached via a +// different evaluation path than wantTrustDeniedNoPrincipal (a real, +// existing, issuer-matching provider whose statement actively denies, not an +// unresolvable principal). +func wantTrustDeniedExplicit(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) +} + +// wantTrustDeniedInvalidClaims asserts assumption fails at the claims stage +// (policy.NoIssuerMatch or policy.ConditionFailed) — an existing, correctly +// Federated provider whose Condition (or, elsewhere in this package, +// audience/issuer) didn't satisfy the request. +func wantTrustDeniedInvalidClaims(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.InvalidIdentityTokenClaims()) +} + +// federatedConditionCase is one row of a table-driven trust-policy Condition +// test: a JWT claim (merged over the base iss/aud/sub/exp claims +// runFederatedConditionCases always supplies) paired with the Condition +// block a role's trust policy scopes, and whether that combination should +// let evaluation reach the network stage (wantTrustAllowed's proxy for +// "Allowed") or fail with InvalidIdentityTokenClaims. +type federatedConditionCase struct { + name string + claims map[string]any + condition func(host string) json.RawMessage + wantAllowed bool +} + +// runFederatedConditionCases runs each case against its own fresh +// provider/role (see newFederatedRole), always using defaultTestAudience so +// a case's outcome is driven solely by its own condition/claim, never an +// incidental audience mismatch. +func runFederatedConditionCases(root *iam.Client, s *S3Conf, cases []federatedConditionCase) error { + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + return trustDoc(trustStatement{ + Effect: "Allow", + Principal: map[string]any{"Federated": providerArn}, + Action: "sts:AssumeRoleWithWebIdentity", + Condition: tc.condition(trimProviderScheme(providerURL)), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + claims := map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999} + for k, v := range tc.claims { + claims[k] = v + } + token := mustToken(claims) + + if tc.wantAllowed { + return wantTrustAllowed(s, roleArn, token) + } + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil +} + +// callerSourceIP returns the IP address the gateway will observe as +// aws:SourceIp for requests made through s's configured endpoint — derived +// from the endpoint's own host rather than assumed, since loopback +// connections use the destination address as their source (no NAT), and the +// integration harness always points s's endpoint at a literal loopback IP +// (see runiamtests.sh). Returns an error rather than guessing if the +// endpoint's host isn't a literal IP, so an IP-condition test fails loudly +// instead of silently asserting against the wrong address. +func callerSourceIP(s *S3Conf) (string, error) { + u, err := url.Parse(s.endpoint) + if err != nil { + return "", fmt.Errorf("parse endpoint %q: %w", s.endpoint, err) + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("endpoint %q has no host", s.endpoint) + } + return host, nil +} diff --git a/tests/integration/iam_assume_role_with_web_identity.go b/tests/integration/iam_assume_role_with_web_identity.go new file mode 100644 index 00000000..84622aea --- /dev/null +++ b/tests/integration/iam_assume_role_with_web_identity.go @@ -0,0 +1,687 @@ +// 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" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/versity/versitygw/iamapi/iamerr" +) + +// stsUnauthConfig builds an authConfig for AssumeRoleWithWebIdentity, the +// one action in this whole gateway that requires no credentials at all: it +// still gets signed (as root, for convenience — reusing authHandler's +// request-building/runF/failF/passF plumbing) but the signature is never +// even checked server-side, so every request-validation test below reaches +// the server's own validation exactly as an entirely unsigned client would. +func stsUnauthConfig(testName string, params url.Values) *authConfig { + if !params.Has("Version") { + params.Set("Version", "2011-06-15") + } + return &authConfig{ + testName: testName, + method: http.MethodPost, + service: "sts", + region: iamAuthRegion, + body: []byte(params.Encode()), + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + } +} + +// checkSTSApiErr checks resp against expected, the way requireSTSError does +// in the iamapi package's own controller-level tests: STS errors render +// under a different XML namespace than IAM's (STSNamespace, or +// AWSFaultNamespace for InvalidAction specifically), so this can't reuse +// checkHTTPResponseIAMErr, which hard-codes iamerr.Namespace. +func checkSTSApiErr(resp *http.Response, expected iamerr.Error) error { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != expected.HTTPStatusCode { + return fmt.Errorf("expected response status code to be %v, instead got %v: %s", expected.HTTPStatusCode, resp.StatusCode, body) + } + + var errResp IAMErrorResponse + if err := xml.Unmarshal(body, &errResp); err != nil { + return fmt.Errorf("unmarshal STS error response: %w: %s", err, body) + } + + wantNamespace := iamerr.STSNamespace + if expected.Code == "InvalidAction" { + wantNamespace = iamerr.AWSFaultNamespace + } + if errResp.XMLName.Space != wantNamespace { + return fmt.Errorf("expected STS error namespace %q, instead got %q", wantNamespace, errResp.XMLName.Space) + } + if errResp.Error.Type != string(expected.Type) || errResp.Error.Code != expected.Code || errResp.Error.Message != expected.Message { + return fmt.Errorf("expected error type=%q code=%q message=%q, instead got type=%q code=%q message=%q", + expected.Type, expected.Code, expected.Message, errResp.Error.Type, errResp.Error.Code, errResp.Error.Message) + } + if errResp.RequestID == "" { + return fmt.Errorf("expected STS error response request id") + } + return nil +} + +// webIdentityTokenWithClaims builds an unverified (but structurally valid) +// JWT carrying claims. Sufficient for every trust-evaluation test below, +// none of which ever reach real signature verification (a trust-policy +// mismatch, audience mismatch, or condition failure is always detected +// first) — the sole exception, the IDP communication error test, needs +// exactly this and no more: real signature verification never succeeds +// against a fake identity provider regardless of what the token contains. +func webIdentityTokenWithClaims(claims map[string]any) (string, error) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".c2lnbmF0dXJl", nil +} + +// validWebIdentityToken is a structurally valid (but unverifiable — no +// registered provider will ever match its issuer) JWT carrying every claim +// AWS requires (including iat — its absence would itself be a rejection +// reason, see VerifyWebIdentityRequiredClaims), sufficient for exercising +// every AssumeRoleWithWebIdentity validation step that runs before a role is +// even looked up. +const validWebIdentityToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJpc3MiOiJodHRwczovL3VucmVnaXN0ZXJlZC5leGFtcGxlLmNvbSIsImF1ZCI6ImNsaWVudDEiLCJzdWIiOiJ1c2VyMSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ." + + "c2lnbmF0dXJl" + +func IAMAssumeRoleWithWebIdentity_missing_role_arn(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_missing_role_arn" + cfg := stsUnauthConfig(testName, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MissingValue("roleArn")) + }) +} + +func IAMAssumeRoleWithWebIdentity_role_arn_too_short(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_role_arn_too_short" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"short"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.ValueTooShort("roleArn", 20)) + }) +} + +func IAMAssumeRoleWithWebIdentity_malformed_duration(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_malformed_duration" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"notanumber"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MalformedInput()) + }) +} + +func IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action" + cfg := stsUnauthConfig(testName, url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "Version": {"2010-05-08"}}) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.InvalidAction("AssumeRoleWithWebIdentity", "2010-05-08")) + }) +} + +func IAMAssumeRoleWithWebIdentity_malformed_token(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_malformed_token" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", "not-a-real-jwt-token", 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenMalformed()) + }) +} + +func IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + // The role's default MaxSessionDuration is 3600. + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 7200) + return checkIAMApiErr(assumeErr, iamerr.DurationExceedsMaxSessionDuration()) + }) +} + +func IAMAssumeRoleWithWebIdentity_nonexistent_role(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_nonexistent_role" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + roleArn := "arn:aws:iam::000000000000:role/" + genRandString(16) + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_no_matching_principal(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_no_matching_principal" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The trust policy's Federated principal never corresponds to a + // real, registered OIDC provider (it was never created) — reported + // identically to a nonexistent role, never confirming or denying + // whether the role itself exists. + roleName := "dangling-trust-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, + oidcProviderArn("https://never-created-"+genRandString(12)+".example.com")) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_no_issuer_match(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_no_issuer_match" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The Federated principal resolves to a real, registered provider — + // but that provider's own Url doesn't match the token's iss claim. + // Unlike no_matching_principal, this confirms the role exists + // (InvalidIdentityToken instead of AccessDenied). + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "https://different-issuer-" + genRandString(8) + ".example.com", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_condition_failed(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_condition_failed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + providerArn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "condition-failed-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:sub":"expected-user"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "client1", "sub": "someone-else", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_explicit_deny(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_explicit_deny" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + providerArn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "explicit-deny-" + genRandString(12) + // A broad Allow is present, but a Deny statement matching the same + // provider/action/condition takes precedence — reported as + // AccessDenied, never InvalidIdentityToken. + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"},`+ + `{"Effect":"Deny","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:sub":"blocked-user"}}}]}`, providerArn, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "client1", "sub": "blocked-user", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "allowed-client") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "not-the-allowed-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_empty_client_id_list(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_empty_client_id_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + // No ClientIDList entries at all — can never satisfy the audience + // check, no matter what the token's aud claim is. + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "anything", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +// IAMAssumeRoleWithWebIdentity_idp_communication_error confirms the +// network-dependent signature-verification step is wired all the way +// through the real HTTP action handler: a provider Url that's a loopback IP +// literal is rejected by VerifyWebIdentitySignature's mandatory SSRF guard +// before any real network attempt, deterministically and without requiring +// outbound network access from the test environment — the same technique +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// uses for CreateOpenIDConnectProvider's own auto-fetch path. +func IAMAssumeRoleWithWebIdentity_idp_communication_error(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_idp_communication_error" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, "https://127.0.0.1", "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "https://127.0.0.1", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenIDPCommunicationError()) + }) +} + +func IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The role is created with the default "/" path, so its real Arn is + // arn:...:role/ — not arn:...:role/some/path/. Only the + // role name (the ARN's final path segment) is used to look the role + // up; the full ARN, path included, must still match the role's + // actual Arn, or trust is never evaluated at all. + roleName := "path-mismatch-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, + oidcProviderArn("https://never-created-"+genRandString(12)+".example.com")) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + roleArn := "arn:aws:iam::000000000000:role/some/path/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +// IAMAssumeRoleWithWebIdentity_policy_arns_rejected and +// IAMAssumeRoleWithWebIdentity_provider_id_rejected confirm PolicyArns and +// ProviderId — valid AssumeRoleWithWebIdentity parameters this +// implementation doesn't support — are rejected outright rather than +// silently ignored. Both checks run before the role is even looked up, so +// (matching the other request-validation tests above) RoleArn need not name +// a real role. +func IAMAssumeRoleWithWebIdentity_policy_arns_rejected(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_policy_arns_rejected" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.UnsupportedParameter("PolicyArns")) + }) +} + +func IAMAssumeRoleWithWebIdentity_provider_id_rejected(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_provider_id_rejected" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "ProviderId": {"www.amazon.com"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.UnsupportedParameter("ProviderId")) + }) +} + +func IAMAssumeRoleWithWebIdentity_session_policy_too_large(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_session_policy_too_large" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "Policy": {genRandString(2049)}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.ValueTooLong("policy", 2048)) + }) +} + +func IAMAssumeRoleWithWebIdentity_session_policy_invalid(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_session_policy_invalid" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + "Policy": {`{"Version":"2012-10-17"}`}, // no Statement + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MalformedPolicyDocument("Syntax errors in policy.")) + }) +} + +func IAMAssumeRoleWithWebIdentity_oaud_condition_matches(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_oaud_condition_matches" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // A loopback provider URL guarantees a deterministic + // InvalidIdentityToken IDP-communication error once the request + // reaches the network-dependent signature-verification step — + // reaching that far (rather than being rejected earlier by trust + // evaluation) is what confirms the oaud Condition below matched. + providerURL := "https://127.0.0.7" + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"azp-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "oaud-match-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:oaud":"backend-project"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + // azp overrides aud as the effective audience (checked against the + // provider's ClientIDList below), exposing the original aud + // ("backend-project") for the oaud mapping instead. + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "backend-project", "azp": "azp-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenIDPCommunicationError()) + }) +} + +func IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"azp-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "oaud-mismatch-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:oaud":"backend-project"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + // Original aud is "different-project", not "backend-project" — the + // azp-effective audience still matches the provider's ClientIDList, + // so only the oaud Condition is what fails this request. + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "different-project", "azp": "azp-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL + "/", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "http://" + trimProviderScheme(providerURL), "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +// createTestRoleForWebIdentityTrust registers a fresh OIDC provider at +// providerURL (with clientID in its ClientIDList, unless clientID is +// empty) and a role whose trust policy allows sts:AssumeRoleWithWebIdentity +// for that provider with no Condition, returning the role's ARN and a +// cleanup function that removes both. +func createTestRoleForWebIdentityTrust(client *iam.Client, providerURL, clientID string) (roleArn string, cleanup func(), err error) { + var clientIDs []string + if clientID != "" { + clientIDs = []string{clientID} + } + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", nil, err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + roleName := "web-identity-trust-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, providerArn) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + deleteOIDCProvider(client, providerArn) + return "", nil, err + } + + cleanup = func() { + deleteIAMRole(client, roleName) + deleteOIDCProvider(client, providerArn) + } + return "arn:aws:iam::000000000000:role/" + roleName, cleanup, nil +} + +// assumeRoleWithWebIdentity calls AssumeRoleWithWebIdentity through a real +// STS SDK client — the action needs no credentials, so this works +// regardless of what (if anything) s itself is configured to sign with. +// durationSeconds of 0 omits DurationSeconds entirely (STS's own default +// applies). +func assumeRoleWithWebIdentity(s *S3Conf, roleArn, sessionName, token string, durationSeconds int32) (*sts.AssumeRoleWithWebIdentityOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + input := &sts.AssumeRoleWithWebIdentityInput{ + RoleArn: &roleArn, + RoleSessionName: &sessionName, + WebIdentityToken: &token, + } + if durationSeconds > 0 { + input.DurationSeconds = aws.Int32(durationSeconds) + } + return s.GetSTSClient().AssumeRoleWithWebIdentity(ctx, input) +} + +// trimProviderScheme mirrors iamutil.WebIdentityIssuer's scheme-stripping, +// for building Condition context keys (":") against a +// provider's stored (scheme-stripped) Url. +func trimProviderScheme(rawURL string) string { + for _, prefix := range []string{"https://", "http://"} { + if len(rawURL) > len(prefix) && rawURL[:len(prefix)] == prefix { + return rawURL[len(prefix):] + } + } + return rawURL +} diff --git a/tests/integration/iam_get_caller_identity.go b/tests/integration/iam_get_caller_identity.go new file mode 100644 index 00000000..c8959898 --- /dev/null +++ b/tests/integration/iam_get_caller_identity.go @@ -0,0 +1,176 @@ +// 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" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/versity/versitygw/iamapi/iamerr" +) + +// getCallerIdentity calls GetCallerIdentity through a real STS SDK client +// configured with access/secret. +func getCallerIdentity(cfg S3Conf, access, secret string) (*sts.GetCallerIdentityOutput, error) { + cfg.awsID = access + cfg.awsSecret = secret + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return cfg.GetSTSClient().GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} + +func IAMGetCallerIdentity_root_success(s *S3Conf) error { + testName := "IAMGetCallerIdentity_root_success" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + out, err := getCallerIdentity(*s, s.awsID, s.awsSecret) + if err != nil { + return err + } + wantArn := "arn:aws:iam::000000000000:root" + if aws.ToString(out.Arn) != wantArn { + return fmt.Errorf("expected Arn %q, instead got %q", wantArn, aws.ToString(out.Arn)) + } + if aws.ToString(out.UserId) != "000000000000" { + return fmt.Errorf("expected UserId %q, instead got %q", "000000000000", aws.ToString(out.UserId)) + } + if aws.ToString(out.Account) != "000000000000" { + return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account)) + } + return nil + }) +} + +func IAMGetCallerIdentity_user_success(s *S3Conf) error { + testName := "IAMGetCallerIdentity_user_success" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + userName := newIAMUserName() + createOut, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}) + if err != nil { + return err + } + defer func() { + if delErr := deleteIAMUserAndAccessKeys(client, userName); delErr != nil { + err = fmt.Errorf("%w (also: delete user: %v)", err, delErr) + } + }() + userArn := aws.ToString(createOut.User.Arn) + userID := aws.ToString(createOut.User.UserId) + + keyOut, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + + out, err := getCallerIdentity(*s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey)) + if err != nil { + return err + } + if aws.ToString(out.Arn) != userArn { + return fmt.Errorf("expected Arn %q, instead got %q", userArn, aws.ToString(out.Arn)) + } + if aws.ToString(out.UserId) != userID { + return fmt.Errorf("expected UserId %q, instead got %q", userID, aws.ToString(out.UserId)) + } + if aws.ToString(out.Account) != "000000000000" { + return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account)) + } + return nil + }) +} + +func IAMGetCallerIdentity_unknown_access_key(s *S3Conf) error { + testName := "IAMGetCallerIdentity_unknown_access_key" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + _, err := getCallerIdentity(*s, "AKIAuNKNOWNACCESSKEYID", "does-not-matter") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)) + }) +} + +func IAMGetCallerIdentity_no_auth(s *S3Conf) error { + testName := "IAMGetCallerIdentity_no_auth" + runF(testName) + + body := []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode()) + req, err := http.NewRequest(http.MethodPost, s.endpoint+"/", bytes.NewReader(body)) + if err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.httpClient.Do(req) + if err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + if err := checkSTSApiErr(resp, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)); err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + + passF(testName) + return nil +} + +func IAMGetCallerIdentity_wrong_version_is_invalid_action(s *S3Conf) error { + testName := "IAMGetCallerIdentity_wrong_version_is_invalid_action" + cfg := &authConfig{ + testName: testName, + method: http.MethodPost, + service: "sts", + region: iamAuthRegion, + body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2010-05-08"}}.Encode()), + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + } + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.InvalidAction("GetCallerIdentity", "2010-05-08")) + }) +} + +// IAMGetCallerIdentity_incorrect_service_scope confirms the shared sigv4 +// auth pipeline reports the STS-specific service name ("sts", not "iam") +// when GetCallerIdentity is signed with a Credential scoped to the wrong +// service. +func IAMGetCallerIdentity_incorrect_service_scope(s *S3Conf) error { + testName := "IAMGetCallerIdentity_incorrect_service_scope" + cfg := &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", // wrong: GetCallerIdentity expects "sts" + region: iamAuthRegion, + body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode()), + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + } + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.IncorrectServiceScope("sts")) + }) +} diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index 71506012..d47acb4d 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -29,6 +29,7 @@ import ( "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/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go/middleware" ) @@ -158,6 +159,11 @@ func (c *S3Conf) GetIAMClient() *iam.Client { return iam.NewFromConfig(c.Config()) } +// GetSTSClient returns an SDK client for STS actions +func (c *S3Conf) GetSTSClient() *sts.Client { + return sts.NewFromConfig(c.Config()) +} + func (c *S3Conf) GetPresignClient() *s3.PresignClient { return s3.NewPresignClient(c.GetClient()) } From 2e22a4232461182e979bfe1484dcd1e3aa1a172c Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 5 Aug 2026 17:23:08 +0400 Subject: [PATCH 08/10] feat: add live GitHub OIDC end-to-end test for AssumeRoleWithWebIdentity Add IAMAssumeRoleWithWebIdentity_github_oidc_live, the only web-identity test that exercises AssumeRoleWithWebIdentity against a real external OIDC provider end-to-end: GitHub Actions' own issuer, with real discovery-document fetch, JWKS fetch, RS256 signature verification, claims mapping, and session credential issuance. Every other web-identity test in the suite uses a fake token that never reaches real signature verification. The test registers a throwaway OIDC provider and trust role scoped to this repo (via a distinct test audience and repo-scoped sub condition), fetches a real ID token from GitHub's runtime endpoint, assumes the role, and confirms the issued session credentials work with a follow-up GetCallerIdentity call. It cleans up the role and provider unconditionally and skips itself when run outside a GitHub Actions job with id-token: write permission (e.g. local runs or fork PRs, where GitHub downgrades OIDC permissions to read-only). Add functional-iam-oidc.yml to run this test in CI on push to main and on same-repo pull_request runs, isolated from the full iam suite since it's the only test needing id-token: write. Add a SKIP counter and skipF() alongside the existing runF/passF/failF, and report it in the final RAN/PASS/FAIL summary, so a test opting out via skipF() (as this one does when OIDC env vars aren't present) is visible instead of silently absent from the count. --- .github/workflows/functional-iam-oidc.yml | 88 +++++++ cmd/versitygw/test.go | 4 +- tests/integration/group-tests.go | 190 +++++++------- ...sume_role_with_web_identity_github_oidc.go | 242 ++++++++++++++++++ tests/integration/output.go | 15 +- 5 files changed, 439 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/functional-iam-oidc.yml create mode 100644 tests/integration/iam_assume_role_with_web_identity_github_oidc.go diff --git a/.github/workflows/functional-iam-oidc.yml b/.github/workflows/functional-iam-oidc.yml new file mode 100644 index 00000000..0c7a11d9 --- /dev/null +++ b/.github/workflows/functional-iam-oidc.yml @@ -0,0 +1,88 @@ +name: IAM functional tests (GitHub OIDC live) + +# This workflow exercises AssumeRoleWithWebIdentity against a REAL external +# OIDC identity provider (GitHub Actions' own OIDC issuer) - the one publicly +# reachable, free IdP available from inside our own CI job, so no self-hosted +# IdP container is needed. +# +# Trigger stays plain `pull_request` (never pull_request_target or +# workflow_run) plus `push` to main. On a pull_request run, GitHub itself +# downgrades GITHUB_TOKEN/OIDC permissions to read-only whenever the PR +# comes from a fork - regardless of what this file requests - so +# ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN simply won't +# exist in that case and the test below skips itself. That's the actual +# security boundary here: a hostile fork-PR author cannot use their own PR +# to mint a token scoped to this repo's identity through this workflow. Only +# a same-repo (non-fork) pull_request run, or a push to main, gets real +# credentials and actually exercises the live OIDC flow. +permissions: + contents: read + id-token: write + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + name: RunIAMGitHubOIDCTest + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "stable" + id: go + + - name: Get Dependencies + run: | + go mod download + + - name: Build + run: | + make testbin + + - name: Run GitHub OIDC live web-identity test + run: | + set -Eeuo pipefail + + IAM_PID="" + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$IAM_PID" ]] && kill -0 "$IAM_PID" 2>/dev/null; then + kill "$IAM_PID" 2>/dev/null || true + fi + if [[ -n "$IAM_PID" ]]; then + wait "$IAM_PID" 2>/dev/null || true + fi + exit "$status" + } + trap cleanup EXIT + + mkdir -p /tmp/iam-oidc + ./versitygw --health /healthz -p :7078 -a user -s pass iam --dir /tmp/iam-oidc & + IAM_PID=$! + + ready="" + for _ in {1..50}; do + if curl --fail --silent --max-time 1 http://127.0.0.1:7078/healthz >/dev/null 2>&1; then + ready=1 + break + fi + if ! kill -0 "$IAM_PID" 2>/dev/null; then + echo "IAM API server stopped before becoming ready" >&2 + exit 1 + fi + sleep 0.2 + done + if [[ -z "$ready" ]]; then + echo "timed out waiting for IAM API server" >&2 + exit 1 + fi + + ./versitygw test -a user -s pass -e http://127.0.0.1:7078 IAMAssumeRoleWithWebIdentity_github_oidc_live diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 7525fcfc..075305fe 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -420,7 +420,7 @@ func websiteHostingAction(ctx *cli.Context) error { ts.Wait() fmt.Println() - fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load()) + fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load(), "SKIP:", integration.SkipCount.Load()) if integration.FailCount.Load() > 0 { return fmt.Errorf("test failed with %v errors", integration.FailCount.Load()) } @@ -462,7 +462,7 @@ func getAction(tf testFunc) func(ctx *cli.Context) error { ts.Wait() fmt.Println() - fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load()) + fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load(), "SKIP:", integration.SkipCount.Load()) if integration.FailCount.Load() > 0 { return fmt.Errorf("test failed with %v errors", integration.FailCount.Load()) } diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 4c61d94e..b5e9350d 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1474,6 +1474,7 @@ func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_github_oidc_live) } func TestIAMGetCallerIdentity(ts *TestState) { @@ -2177,6 +2178,101 @@ func GetIntTests() IntTests { "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, + "IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn, + "IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short, + "IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration, + "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action": IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action, + "IAMAssumeRoleWithWebIdentity_malformed_token": IAMAssumeRoleWithWebIdentity_malformed_token, + "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max": IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max, + "IAMAssumeRoleWithWebIdentity_nonexistent_role": IAMAssumeRoleWithWebIdentity_nonexistent_role, + "IAMAssumeRoleWithWebIdentity_no_matching_principal": IAMAssumeRoleWithWebIdentity_no_matching_principal, + "IAMAssumeRoleWithWebIdentity_no_issuer_match": IAMAssumeRoleWithWebIdentity_no_issuer_match, + "IAMAssumeRoleWithWebIdentity_condition_failed": IAMAssumeRoleWithWebIdentity_condition_failed, + "IAMAssumeRoleWithWebIdentity_explicit_deny": IAMAssumeRoleWithWebIdentity_explicit_deny, + "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list": IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list, + "IAMAssumeRoleWithWebIdentity_empty_client_id_list": IAMAssumeRoleWithWebIdentity_empty_client_id_list, + "IAMAssumeRoleWithWebIdentity_idp_communication_error": IAMAssumeRoleWithWebIdentity_idp_communication_error, + "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch": IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch, + "IAMAssumeRoleWithWebIdentity_policy_arns_rejected": IAMAssumeRoleWithWebIdentity_policy_arns_rejected, + "IAMAssumeRoleWithWebIdentity_provider_id_rejected": IAMAssumeRoleWithWebIdentity_provider_id_rejected, + "IAMAssumeRoleWithWebIdentity_session_policy_too_large": IAMAssumeRoleWithWebIdentity_session_policy_too_large, + "IAMAssumeRoleWithWebIdentity_session_policy_invalid": IAMAssumeRoleWithWebIdentity_session_policy_invalid, + "IAMAssumeRoleWithWebIdentity_oaud_condition_matches": IAMAssumeRoleWithWebIdentity_oaud_condition_matches, + "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, + "IAMAssumeRoleWithWebIdentity_github_oidc_live": IAMAssumeRoleWithWebIdentity_github_oidc_live, + "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, + "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, + "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, + "IAMGetCallerIdentity_no_auth": IAMGetCallerIdentity_no_auth, + "IAMGetCallerIdentity_wrong_version_is_invalid_action": IAMGetCallerIdentity_wrong_version_is_invalid_action, + "IAMGetCallerIdentity_incorrect_service_scope": IAMGetCallerIdentity_incorrect_service_scope, + "IAMAccessControl_ImplicitDenyNoMatchingPolicy": IAMAccessControl_ImplicitDenyNoMatchingPolicy, + "IAMAccessControl_AllowGrantsMatchingRequest": IAMAccessControl_AllowGrantsMatchingRequest, + "IAMAccessControl_NonMatchingStatementDoesNotGrant": IAMAccessControl_NonMatchingStatementDoesNotGrant, + "IAMAccessControl_ExplicitDenyOverridesAllow": IAMAccessControl_ExplicitDenyOverridesAllow, + "IAMAccessControl_MultipleStatementsEvaluatedIndependently": IAMAccessControl_MultipleStatementsEvaluatedIndependently, + "IAMAccessControl_MultipleInlinePoliciesCombinedAllow": IAMAccessControl_MultipleInlinePoliciesCombinedAllow, + "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins": IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins, + "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies": IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies, + "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow": IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow, + "IAMAccessControl_ActionMatchingVariants": IAMAccessControl_ActionMatchingVariants, + "IAMAccessControl_ActionAllowOneDenyAnotherByOmission": IAMAccessControl_ActionAllowOneDenyAnotherByOmission, + "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow": IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow, + "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded": IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded, + "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded": IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded, + "IAMAccessControl_ResourceMatchingVariants": IAMAccessControl_ResourceMatchingVariants, + "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction": IAMAccessControl_ResourceOneAllowedOneDeniedSameAction, + "IAMAccessControl_ResourceWildcardRequiredForListAction": IAMAccessControl_ResourceWildcardRequiredForListAction, + "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow": IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_NotResourceExcludesTarget": IAMAccessControl_NotResourceExcludesTarget, + "IAMAccessControl_NotResourceMultipleExcludedResources": IAMAccessControl_NotResourceMultipleExcludedResources, + "IAMAccessControl_NotResourceWildcardExclusion": IAMAccessControl_NotResourceWildcardExclusion, + "IAMAccessControl_ConditionStringOperators": IAMAccessControl_ConditionStringOperators, + "IAMAccessControl_ConditionStringMultipleExpectedValuesOR": IAMAccessControl_ConditionStringMultipleExpectedValuesOR, + "IAMAccessControl_ConditionArnOperators": IAMAccessControl_ConditionArnOperators, + "IAMAccessControl_ConditionIpAddressRealSourceIp": IAMAccessControl_ConditionIpAddressRealSourceIp, + "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow": IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_ConditionMultipleContextKeysANDed": IAMAccessControl_ConditionMultipleContextKeysANDed, + "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply": IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply, + "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins": IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins, + "IAMAccessControl_ConditionOneFailedConditionVoidsStatement": IAMAccessControl_ConditionOneFailedConditionVoidsStatement, + "IAMAccessControl_ConditionNullPrincipalTag": IAMAccessControl_ConditionNullPrincipalTag, + "IAMAccessControl_ConditionIfExistsPrincipalTag": IAMAccessControl_ConditionIfExistsPrincipalTag, + "IAMAccessControl_ConditionResourceTagOnTarget": IAMAccessControl_ConditionResourceTagOnTarget, + "IAMAccessControl_ConditionRequestTagOnCreateUser": IAMAccessControl_ConditionRequestTagOnCreateUser, + "IAMAccessControl_ConditionCurrentTimeBroadWindow": IAMAccessControl_ConditionCurrentTimeBroadWindow, + "IAMAccessControl_ConditionNumericOperators": IAMAccessControl_ConditionNumericOperators, + "IAMAccessControl_ConditionDateOperators": IAMAccessControl_ConditionDateOperators, + "IAMAccessControl_ConditionBoolOperator": IAMAccessControl_ConditionBoolOperator, + "IAMAccessControl_ConditionNullOperatorClaim": IAMAccessControl_ConditionNullOperatorClaim, + "IAMAccessControl_ConditionBinaryEqualsOperator": IAMAccessControl_ConditionBinaryEqualsOperator, + "IAMAccessControl_ConditionForAnyValueOperator": IAMAccessControl_ConditionForAnyValueOperator, + "IAMAccessControl_ConditionForAllValuesOperator": IAMAccessControl_ConditionForAllValuesOperator, + "IAMAccessControl_ConditionIfExistsTrustClaim": IAMAccessControl_ConditionIfExistsTrustClaim, + "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust": IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust, + "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed": IAMAccessControl_TrustPolicyFederatedExactMatchAllowed, + "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied": IAMAccessControl_TrustPolicyFederatedWrongProviderDenied, + "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny": IAMAccessControl_TrustPolicyFederatedArrayMatchesAny, + "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored": IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored, + "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed": IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed, + "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied": IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied, + "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed": IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed, + "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied": IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied, + "IAMAccessControl_TrustPolicyAudienceCorrectAllowed": IAMAccessControl_TrustPolicyAudienceCorrectAllowed, + "IAMAccessControl_TrustPolicyAudienceIncorrectDenied": IAMAccessControl_TrustPolicyAudienceIncorrectDenied, + "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed": IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed, + "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch": IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch, + "IAMAccessControl_TrustPolicyExplicitDenyStatement": IAMAccessControl_TrustPolicyExplicitDenyStatement, + "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants": IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants, + "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied": IAMAccessControl_TrustPolicyMissingRequiredClaimDenied, + "IAMAccessControl_UserInlinePolicyWorkflow": IAMAccessControl_UserInlinePolicyWorkflow, + "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath": IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath, + "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision": IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision, + "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy": IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy, + "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer": IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer, + "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck": IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, @@ -2989,99 +3085,5 @@ func GetIntTests() IntTests { "PostObject_multiple_checksum_headers": PostObject_multiple_checksum_headers, "PostObject_checksums_success": PostObject_checksums_success, "PostObject_success_double_dash_boundary": PostObject_success_double_dash_boundary, - "IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn, - "IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short, - "IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration, - "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action": IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action, - "IAMAssumeRoleWithWebIdentity_malformed_token": IAMAssumeRoleWithWebIdentity_malformed_token, - "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max": IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max, - "IAMAssumeRoleWithWebIdentity_nonexistent_role": IAMAssumeRoleWithWebIdentity_nonexistent_role, - "IAMAssumeRoleWithWebIdentity_no_matching_principal": IAMAssumeRoleWithWebIdentity_no_matching_principal, - "IAMAssumeRoleWithWebIdentity_no_issuer_match": IAMAssumeRoleWithWebIdentity_no_issuer_match, - "IAMAssumeRoleWithWebIdentity_condition_failed": IAMAssumeRoleWithWebIdentity_condition_failed, - "IAMAssumeRoleWithWebIdentity_explicit_deny": IAMAssumeRoleWithWebIdentity_explicit_deny, - "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list": IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list, - "IAMAssumeRoleWithWebIdentity_empty_client_id_list": IAMAssumeRoleWithWebIdentity_empty_client_id_list, - "IAMAssumeRoleWithWebIdentity_idp_communication_error": IAMAssumeRoleWithWebIdentity_idp_communication_error, - "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch": IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch, - "IAMAssumeRoleWithWebIdentity_policy_arns_rejected": IAMAssumeRoleWithWebIdentity_policy_arns_rejected, - "IAMAssumeRoleWithWebIdentity_provider_id_rejected": IAMAssumeRoleWithWebIdentity_provider_id_rejected, - "IAMAssumeRoleWithWebIdentity_session_policy_too_large": IAMAssumeRoleWithWebIdentity_session_policy_too_large, - "IAMAssumeRoleWithWebIdentity_session_policy_invalid": IAMAssumeRoleWithWebIdentity_session_policy_invalid, - "IAMAssumeRoleWithWebIdentity_oaud_condition_matches": IAMAssumeRoleWithWebIdentity_oaud_condition_matches, - "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, - "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, - "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, - "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, - "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, - "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, - "IAMGetCallerIdentity_no_auth": IAMGetCallerIdentity_no_auth, - "IAMGetCallerIdentity_wrong_version_is_invalid_action": IAMGetCallerIdentity_wrong_version_is_invalid_action, - "IAMGetCallerIdentity_incorrect_service_scope": IAMGetCallerIdentity_incorrect_service_scope, - "IAMAccessControl_ImplicitDenyNoMatchingPolicy": IAMAccessControl_ImplicitDenyNoMatchingPolicy, - "IAMAccessControl_AllowGrantsMatchingRequest": IAMAccessControl_AllowGrantsMatchingRequest, - "IAMAccessControl_NonMatchingStatementDoesNotGrant": IAMAccessControl_NonMatchingStatementDoesNotGrant, - "IAMAccessControl_ExplicitDenyOverridesAllow": IAMAccessControl_ExplicitDenyOverridesAllow, - "IAMAccessControl_MultipleStatementsEvaluatedIndependently": IAMAccessControl_MultipleStatementsEvaluatedIndependently, - "IAMAccessControl_MultipleInlinePoliciesCombinedAllow": IAMAccessControl_MultipleInlinePoliciesCombinedAllow, - "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins": IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins, - "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies": IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies, - "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow": IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow, - "IAMAccessControl_ActionMatchingVariants": IAMAccessControl_ActionMatchingVariants, - "IAMAccessControl_ActionAllowOneDenyAnotherByOmission": IAMAccessControl_ActionAllowOneDenyAnotherByOmission, - "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow": IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow, - "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded": IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded, - "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded": IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded, - "IAMAccessControl_ResourceMatchingVariants": IAMAccessControl_ResourceMatchingVariants, - "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction": IAMAccessControl_ResourceOneAllowedOneDeniedSameAction, - "IAMAccessControl_ResourceWildcardRequiredForListAction": IAMAccessControl_ResourceWildcardRequiredForListAction, - "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow": IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow, - "IAMAccessControl_NotResourceExcludesTarget": IAMAccessControl_NotResourceExcludesTarget, - "IAMAccessControl_NotResourceMultipleExcludedResources": IAMAccessControl_NotResourceMultipleExcludedResources, - "IAMAccessControl_NotResourceWildcardExclusion": IAMAccessControl_NotResourceWildcardExclusion, - "IAMAccessControl_ConditionStringOperators": IAMAccessControl_ConditionStringOperators, - "IAMAccessControl_ConditionStringMultipleExpectedValuesOR": IAMAccessControl_ConditionStringMultipleExpectedValuesOR, - "IAMAccessControl_ConditionArnOperators": IAMAccessControl_ConditionArnOperators, - "IAMAccessControl_ConditionIpAddressRealSourceIp": IAMAccessControl_ConditionIpAddressRealSourceIp, - "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow": IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow, - "IAMAccessControl_ConditionMultipleContextKeysANDed": IAMAccessControl_ConditionMultipleContextKeysANDed, - "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply": IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply, - "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins": IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins, - "IAMAccessControl_ConditionOneFailedConditionVoidsStatement": IAMAccessControl_ConditionOneFailedConditionVoidsStatement, - "IAMAccessControl_ConditionNullPrincipalTag": IAMAccessControl_ConditionNullPrincipalTag, - "IAMAccessControl_ConditionIfExistsPrincipalTag": IAMAccessControl_ConditionIfExistsPrincipalTag, - "IAMAccessControl_ConditionResourceTagOnTarget": IAMAccessControl_ConditionResourceTagOnTarget, - "IAMAccessControl_ConditionRequestTagOnCreateUser": IAMAccessControl_ConditionRequestTagOnCreateUser, - "IAMAccessControl_ConditionCurrentTimeBroadWindow": IAMAccessControl_ConditionCurrentTimeBroadWindow, - "IAMAccessControl_ConditionNumericOperators": IAMAccessControl_ConditionNumericOperators, - "IAMAccessControl_ConditionDateOperators": IAMAccessControl_ConditionDateOperators, - "IAMAccessControl_ConditionBoolOperator": IAMAccessControl_ConditionBoolOperator, - "IAMAccessControl_ConditionNullOperatorClaim": IAMAccessControl_ConditionNullOperatorClaim, - "IAMAccessControl_ConditionBinaryEqualsOperator": IAMAccessControl_ConditionBinaryEqualsOperator, - "IAMAccessControl_ConditionForAnyValueOperator": IAMAccessControl_ConditionForAnyValueOperator, - "IAMAccessControl_ConditionForAllValuesOperator": IAMAccessControl_ConditionForAllValuesOperator, - "IAMAccessControl_ConditionIfExistsTrustClaim": IAMAccessControl_ConditionIfExistsTrustClaim, - "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust": IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust, - "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed": IAMAccessControl_TrustPolicyFederatedExactMatchAllowed, - "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied": IAMAccessControl_TrustPolicyFederatedWrongProviderDenied, - "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny": IAMAccessControl_TrustPolicyFederatedArrayMatchesAny, - "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored": IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored, - "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed": IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed, - "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied": IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied, - "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed": IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed, - "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied": IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied, - "IAMAccessControl_TrustPolicyAudienceCorrectAllowed": IAMAccessControl_TrustPolicyAudienceCorrectAllowed, - "IAMAccessControl_TrustPolicyAudienceIncorrectDenied": IAMAccessControl_TrustPolicyAudienceIncorrectDenied, - "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed": IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed, - "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch": IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch, - "IAMAccessControl_TrustPolicyExplicitDenyStatement": IAMAccessControl_TrustPolicyExplicitDenyStatement, - "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants": IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants, - "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied": IAMAccessControl_TrustPolicyMissingRequiredClaimDenied, - "IAMAccessControl_UserInlinePolicyWorkflow": IAMAccessControl_UserInlinePolicyWorkflow, - "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath": IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath, - "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision": IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision, - "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy": IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy, - "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer": IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer, - "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck": IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck, } } diff --git a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go new file mode 100644 index 00000000..61a5600e --- /dev/null +++ b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go @@ -0,0 +1,242 @@ +// 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" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +const ( + // githubOIDCIssuerURL is GitHub Actions' own OIDC token issuer: a real, + // publicly reachable HTTPS endpoint with a CA-issued certificate. + githubOIDCIssuerURL = "https://token.actions.githubusercontent.com" + + // githubOIDCTestAudience is deliberately distinct from GitHub's default + // audience (which is the caller's own server URL). If this org ever + // configures a real cloud-provider role trusting + // token.actions.githubusercontent.com for this repo (e.g. for + // publishing/deploys), a leaked test token must not be replayable + // against that unrelated trust relationship - binding the throwaway + // role's trust policy to this audience (instead of GitHub's default) + // is what prevents that. + githubOIDCTestAudience = "versitygw-integration-tests" +) + +// IAMAssumeRoleWithWebIdentity_github_oidc_live exercises +// AssumeRoleWithWebIdentity against a REAL external OIDC identity provider — +// GitHub Actions' own OIDC issuer — end-to-end: discovery-document fetch, +// JWKS fetch, real RS256 signature verification, claims mapping, and +// session credential issuance. It's the only web-identity test that does +// this; every other one in this package uses a fake token that never +// reaches real signature verification. +func IAMAssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_github_oidc_live" + + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + skipF("%v: ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set "+ + "(expected outside a GitHub Actions job with id-token: write permission)", testName) + return nil + } + + return iamActionHandler(s, testName, func(client *iam.Client) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set, but ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are - unexpected environment") + } + + roleName, roleArn, cleanup, err := createGitHubOIDCTrust(client, repo) + if err != nil { + return err + } + defer cleanup() + + token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience) + if err != nil { + return err + } + + const sessionName = "github-oidc-live" + assumeOut, err := assumeRoleWithWebIdentity(s, roleArn, sessionName, token, 0) + if err != nil { + // checkIAMApiErr-style wrapping isn't used here since a live + // AssumeRoleWithWebIdentity SDK error carries no token material + // of its own to guard against - it's the request we build + // (never printed) and GitHub's response (never printed either, + // see fetchGitHubIDToken) that could leak the token. + return fmt.Errorf("AssumeRoleWithWebIdentity: %w", err) + } + if assumeOut.Credentials == nil { + return fmt.Errorf("expected Credentials in AssumeRoleWithWebIdentity response") + } + accessKeyID := aws.ToString(assumeOut.Credentials.AccessKeyId) + secretAccessKey := aws.ToString(assumeOut.Credentials.SecretAccessKey) + sessionToken := aws.ToString(assumeOut.Credentials.SessionToken) + if accessKeyID == "" || secretAccessKey == "" || sessionToken == "" { + return fmt.Errorf("expected a full AccessKeyId/SecretAccessKey/SessionToken triple in AssumeRoleWithWebIdentity response") + } + + wantArn := fmt.Sprintf("arn:aws:sts::000000000000:assumed-role/%s/%s", roleName, sessionName) + if aws.ToString(assumeOut.AssumedRoleUser.Arn) != wantArn { + return fmt.Errorf("expected AssumedRoleUser.Arn %q, instead got %q", wantArn, aws.ToString(assumeOut.AssumedRoleUser.Arn)) + } + + // A follow-up call authenticated with the session credentials + // AssumeRoleWithWebIdentity just issued proves the whole chain - + // discovery, JWKS, signature verification, claims mapping, and + // session creds - actually works, not just that a 200 came back. + callerOut, err := getCallerIdentityWithSessionCreds(*s, accessKeyID, secretAccessKey, sessionToken) + if err != nil { + return fmt.Errorf("GetCallerIdentity with assumed-role session credentials: %w", err) + } + if aws.ToString(callerOut.Arn) != wantArn { + return fmt.Errorf("GetCallerIdentity: expected Arn %q, instead got %q", wantArn, aws.ToString(callerOut.Arn)) + } + return nil + }) +} + +// createGitHubOIDCTrust registers a throwaway OIDC provider for GitHub +// Actions' own issuer (ThumbprintList omitted, exercising +// CreateOpenIDConnectProvider's autofetch-and-CA-verify path against a real +// publicly reachable HTTPS endpoint instead of thumbprint pinning) and a +// throwaway role trusting it, returning the role's name, its ARN, and a +// cleanup func that removes both unconditionally. +// +// The trust policy's Condition requires both: +// - the effective audience to equal githubOIDCTestAudience (not GitHub's +// default audience - see that constant's doc comment), and +// - the sub claim to match "repo::*". +// +// The sub match is a repo-wide wildcard rather than pinning an exact +// ref/event suffix: GitHub's sub claim differs by trigger and branch (e.g. +// "repo:o/r:pull_request" for a pull_request event vs. +// "repo:o/r:ref:refs/heads/main" for a push to main), and pinning one exact +// form would make this test fail depending on how it was triggered. That +// tradeoff only holds because this role is created and deleted within a +// single test run - the same repo-wide wildcard left in a real production +// trust policy would grant every workflow run in the repo, on any branch, +// the same trust, which is far too broad outside this throwaway context. +func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn string, cleanup func(), err error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(githubOIDCIssuerURL), + ClientIDList: []string{githubOIDCTestAudience}, + }) + if err != nil { + return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err) + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + host := trimProviderScheme(githubOIDCIssuerURL) + roleName = "github-oidc-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:aud":%q},"StringLike":{"%s:sub":%q}}}]}`, + providerArn, host, githubOIDCTestAudience, host, "repo:"+repo+":*") + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + deleteOIDCProvider(client, providerArn) + return "", "", nil, fmt.Errorf("create GitHub OIDC trust role: %w", err) + } + + roleArn = "arn:aws:iam::000000000000:role/" + roleName + cleanup = func() { + deleteIAMRole(client, roleName) + deleteOIDCProvider(client, providerArn) + } + return roleName, roleArn, cleanup, nil +} + +// githubIDTokenResponse is the JSON body GitHub's runtime ID-token endpoint +// returns: {"value": "", "count": }. Only value is needed here. +type githubIDTokenResponse struct { + Value string `json:"value"` +} + +// fetchGitHubIDToken fetches a real, signed OIDC ID token for audience from +// GitHub Actions' runtime token endpoint (requestURL/requestToken are +// ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN, only present +// inside a GitHub Actions job with id-token: write permission). +// +// The returned token is a real, unmasked bearer credential - unlike a +// secrets.* value, GitHub does not scrub it from logs automatically since it +// never appears in the workflow YAML. Every error path here is deliberately +// built from fixed strings and status codes only, never from the response +// body or the request's Authorization header, so a failure here can never +// leak the token into CI output. +func fetchGitHubIDToken(requestURL, requestToken, audience string) (string, error) { + parsed, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("parse ACTIONS_ID_TOKEN_REQUEST_URL: invalid URL") + } + q := parsed.Query() + q.Set("audience", audience) + parsed.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return "", fmt.Errorf("build GitHub OIDC token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json; api-version=2.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch GitHub OIDC token: request failed") + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("read GitHub OIDC token response: failed after status %d", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub OIDC token endpoint returned status %d", resp.StatusCode) + } + + var out githubIDTokenResponse + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("parse GitHub OIDC token response: malformed JSON") + } + if out.Value == "" { + return "", fmt.Errorf("GitHub OIDC token endpoint returned an empty token value") + } + return out.Value, nil +} + +// getCallerIdentityWithSessionCreds calls GetCallerIdentity authenticated +// with a full access/secret/session-token triple. +func getCallerIdentityWithSessionCreds(cfg S3Conf, access, secret, token string) (*sts.GetCallerIdentityOutput, error) { + cfg.awsID = access + cfg.awsSecret = secret + stsCfg := cfg.Config() + stsCfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return sts.NewFromConfig(stsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} diff --git a/tests/integration/output.go b/tests/integration/output.go index a0b295f4..0d3506c2 100644 --- a/tests/integration/output.go +++ b/tests/integration/output.go @@ -20,16 +20,18 @@ import ( ) var ( - colorReset = "\033[0m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorCyan = "\033[36m" + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorCyan = "\033[36m" + colorYellow = "\033[33m" ) var ( RunCount atomic.Uint32 PassCount atomic.Uint32 FailCount atomic.Uint32 + SkipCount atomic.Uint32 ) func runF(format string, a ...any) { @@ -46,3 +48,8 @@ func passF(format string, a ...any) { PassCount.Add(1) fmt.Printf(colorGreen+"PASS "+colorReset+format+"\n", a...) } + +func skipF(format string, a ...any) { + SkipCount.Add(1) + fmt.Printf(colorYellow+"SKIP "+colorReset+format+"\n", a...) +} From 2147a0c30473dc9314e9106b527d50a36c484594 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Sat, 15 Aug 2026 03:29:37 +0400 Subject: [PATCH 09/10] feat: integrate standalone IAM service with S3 gateway for identity-based policy enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1327 Fixes #1567 Closes #2264 Wires the S3 gateway up to the standalone IAM service so identity policies, not just bucket policies and ACLs, are enforced on the S3 data plane. The gateway authenticates SigV4 requests by calling new private derive-signing-key and resolve-identity endpoints on the IAM service instead of holding secrets itself, and evaluates identity policy through the same PolicyEvaluator path added to auth.VerifyAccess, combined with the bucket policy using explicit-deny-wins precedence. The private endpoints are served over their own mTLS listener (new iamapi/private package, genmtlscerts.sh to generate test material, and client-cert support in internal/netutil), separate from the public IAM API. As part of this the vendored aws/signer/v4 package is deleted and replaced by a pure-Go SigV4 implementation in internal/sigv4auth, which now reads canonical request data directly off the fiber.Ctx instead of reconstructing an http.Request, and is shared by both the S3 request-signing verification and the new private-endpoint signing. DeleteObjects moves from an all-or-nothing authorization check to true partial success: VerifyObjectsAccess evaluates every object in a batch independently against both the identity policy and any object lock, so a denial or a locked object only removes that key from the batch instead of failing the whole request. It also batches the identity-policy round trip and the bucket-policy fetch once per request rather than once per object, and separates plain deletes from versioned ones since a versioned delete needs s3:DeleteObjectVersion rather than s3:DeleteObject. Object lock handling got a few correctness fixes alongside this: a bypass is now modeled as BypassNone/BypassRequested/BypassOverwrite rather than a single bool, because root's blanket ability to override a GOVERNANCE retention should only apply when the client actually asked to bypass it (DeleteObject/DeleteObjects/PutObjectRetention), not when the gateway is silently replacing a locked object via an overwrite, which needs the permission from everyone including root. Retention changes are now correctly classified as an extension (allowed under plain s3:PutObjectRetention) versus a weakening (date or mode change, which needs the bypass permission), and a COMPLIANCE lock can never be weakened by anyone regardless of permissions, matching AWS. Separately, VerifyObjectCopyAccess had a readonly-mode gap: it returned early for root/admin before ever calling VerifyAccess, so the readonly check inside VerifyAccess never ran for them on CopyObject; access checks are now ordered so the readonly gate always applies before any root/admin bypass, for copy as well as every other write path. Bucket policies also gained Condition block support, via a new shared internal/condition package moved out of the IAM policy package since both bucket and identity policies share the same evaluation semantics. It implements the full AWS operator set — String{Equals,NotEquals,EqualsIgnoreCase,NotEqualsIgnoreCase,Like,NotLike}, Numeric{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Date{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Bool, BinaryEquals, Arn{Equals,Like,NotEquals,NotLike}, IpAddress/NotIpAddress, and Null — along with the ForAllValues/ForAnyValue set qualifiers and the IfExists modifier. A new requestConditionContext builds the per-request keys a bucket policy's Condition block can reference — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:UserAgent, aws:Referer, s3:prefix, s3:delimiter, s3:max-keys, s3:x-amz-acl, s3:VersionId — following AWS's own per-action rules for which keys a given S3 operation actually populates. Identity-derived keys such as aws:PrincipalArn and aws:username are deliberately left unwired here, since the gateway has no way to know them; the standalone IAM service fills those in itself when it evaluates an identity policy. Also added new integration test suites for S3-side IAM: s3_iam_access_control.go and s3_iam_session_access_control.go cover identity-policy enforcement and session-credential requests against real S3 operations, alongside expanded OIDC/web-identity coverage and a new runoidctests.sh runner wired into the OIDC GitHub Actions workflow. --- .github/workflows/functional-iam-oidc.yml | 41 +- .github/workflows/shellcheck.yml | 2 +- auth/access-control.go | 473 ++++- auth/access-control_test.go | 494 ++++- auth/acl.go | 22 +- auth/bucket_policy.go | 162 +- auth/bucket_policy_condition.go | 190 ++ auth/bucket_policy_condition_test.go | 190 ++ auth/bucket_policy_principals.go | 2 +- auth/bucket_policy_test.go | 158 ++ auth/condition_context.go | 68 + auth/iam.go | 84 + auth/iam_cache.go | 7 + auth/iam_internal.go | 5 + auth/iam_ipa.go | 5 + auth/iam_ldap.go | 5 + auth/iam_s3_object.go | 5 + auth/iam_single.go | 5 + auth/iam_standalone.go | 505 +++++ auth/iam_standalone_test.go | 356 ++++ auth/iam_vault.go | 5 + auth/object_lock.go | 460 +++-- auth/object_lock_test.go | 318 +++ auth/post_policy.go | 8 + auth/signing_key_provider.go | 119 ++ aws/LICENSE.txt | 202 -- aws/NOTICE.txt | 4 - aws/README.md | 11 - aws/internal/awstesting/unit/unit.go | 61 - aws/signer/internal/v4/cache.go | 115 -- aws/signer/internal/v4/const.go | 40 - aws/signer/internal/v4/header_rules.go | 92 - aws/signer/internal/v4/headers.go | 32 - aws/signer/internal/v4/headers_test.go | 147 -- aws/signer/internal/v4/hmac.go | 13 - aws/signer/internal/v4/host.go | 75 - aws/signer/internal/v4/scope.go | 13 - aws/signer/internal/v4/time.go | 36 - aws/signer/internal/v4/util.go | 80 - aws/signer/internal/v4/util_test.go | 158 -- aws/signer/v4/functional_test.go | 139 -- aws/signer/v4/header_rules.go | 14 - aws/signer/v4/v4.go | 588 ------ aws/signer/v4/v4_test.go | 380 ---- backend/azure/azure.go | 17 +- backend/posix/posix.go | 27 +- cmd/internal/gwcli/iam.go | 25 + cmd/versitygw/iam.go | 5 + cmd/versitygw/main.go | 214 +- cmd/versitygw/test.go | 27 + cmd/vgwrdma/main.go | 10 +- embedgw/embedgw.go | 105 +- embedgw/iam.go | 122 +- genmtlscerts.sh | 71 + go.mod | 1 - go.sum | 2 - iamapi/authentication_test.go | 68 +- iamapi/controller_test.go | 27 +- iamapi/internal/iammiddleware/auth.go | 172 +- iamapi/internal/iammiddleware/errors.go | 8 + iamapi/internal/iammiddleware/policy.go | 158 +- iamapi/internal/iamutil/access_key.go | 15 +- iamapi/internal/iamutil/identity.go | 145 ++ iamapi/internal/iamutil/webidentity.go | 14 +- iamapi/policy/identity.go | 68 +- iamapi/policy/identity_test.go | 66 +- iamapi/policy/trust.go | 27 +- iamapi/policy/validate.go | 7 +- iamapi/policy/webidentity.go | 34 +- iamapi/policy/webidentity_test.go | 2 +- iamapi/private/errors.go | 119 ++ iamapi/private/handlers.go | 174 ++ iamapi/private/identity.go | 103 + iamapi/private/listener.go | 68 + iamapi/private/private_test.go | 694 +++++++ iamapi/private/server.go | 124 ++ iamapi/private/types.go | 114 ++ iamapi/server.go | 11 +- iamapi/storage/vault.go | 32 +- .../condition}/condition.go | 279 ++- .../condition}/condition_test.go | 95 +- internal/httpctx/context_keys.go | 5 +- internal/netutil/clientcert.go | 49 + internal/netutil/multi_listener.go | 46 +- .../netutil/multi_listener_full_test.go | 8 +- internal/netutil/multi_listener_test.go | 200 ++ internal/sigv4auth/auth.go | 11 + internal/sigv4auth/canonical.go | 376 ++++ internal/sigv4auth/canonical_test.go | 151 ++ internal/sigv4auth/ctx.go | 144 ++ internal/sigv4auth/derive.go | 45 + internal/sigv4auth/derive_test.go | 34 + internal/sigv4auth/header_rules.go | 129 ++ internal/sigv4auth/query.go | 133 +- internal/sigv4auth/request.go | 112 + internal/sigv4auth/verify.go | 134 +- runoidctests.sh | 106 + runtests.sh | 58 +- s3api/admin-server.go | 12 +- s3api/controllers/admin.go | 2 +- s3api/controllers/admin_test.go | 15 +- s3api/controllers/base.go | 29 + s3api/controllers/base_test.go | 2 + s3api/controllers/bucket-delete.go | 24 +- s3api/controllers/bucket-get.go | 56 +- s3api/controllers/bucket-head.go | 4 +- s3api/controllers/bucket-post.go | 78 +- s3api/controllers/bucket-post_test.go | 78 +- s3api/controllers/bucket-put.go | 37 +- s3api/controllers/bucket-put_test.go | 46 + s3api/controllers/iam_moq_test.go | 44 + s3api/controllers/object-delete.go | 19 +- s3api/controllers/object-get.go | 28 +- s3api/controllers/object-head.go | 4 +- s3api/controllers/object-post.go | 18 +- s3api/controllers/object-put.go | 34 +- s3api/controllers/object-put_test.go | 85 +- s3api/middlewares/authentication.go | 32 +- s3api/middlewares/host-style-parser.go | 6 + s3api/middlewares/object-post-auth.go | 23 +- s3api/middlewares/object-post-auth_test.go | 4 +- s3api/middlewares/presign-auth.go | 18 +- s3api/middlewares/public-bucket.go | 2 +- s3api/server.go | 11 +- s3api/server_test.go | 4 +- s3api/utils/auth-reader.go | 35 +- s3api/utils/auth_test.go | 55 +- s3api/utils/chunk-reader.go | 6 +- s3api/utils/multi_listener.go | 399 ---- s3api/utils/presign-auth-reader.go | 12 +- s3api/utils/signed-chunk-reader.go | 21 +- s3api/utils/signed_headers_test.go | 130 +- s3api/utils/utils.go | 140 +- s3api/utils/utils_test.go | 99 - s3err/presigned-urls.go | 4 - s3err/s3err.go | 48 + tests/integration/Access_Control.go | 569 +++++- tests/integration/DeleteObjects.go | 193 ++ tests/integration/GetObjectRetention.go | 2 +- tests/integration/PutBucketPolicy.go | 142 +- tests/integration/PutObject.go | 2 +- tests/integration/PutObjectRetention.go | 265 ++- tests/integration/WORM_protection.go | 54 +- tests/integration/group-tests.go | 162 +- ...sume_role_with_web_identity_github_oidc.go | 31 +- tests/integration/iam_query_auth.go | 45 +- tests/integration/presigned_urls.go | 6 +- tests/integration/s3_iam_access_control.go | 1805 +++++++++++++++++ .../s3_iam_session_access_control.go | 786 +++++++ tests/integration/s3_iam_utils.go | 550 +++++ tests/integration/s3conf.go | 22 +- tests/integration/utils.go | 130 +- tests/integration/versioning.go | 4 +- website/handler.go | 4 +- website/server.go | 12 +- webui/webserver.go | 12 +- 156 files changed, 12964 insertions(+), 4376 deletions(-) create mode 100644 auth/bucket_policy_condition.go create mode 100644 auth/bucket_policy_condition_test.go create mode 100644 auth/bucket_policy_test.go create mode 100644 auth/condition_context.go create mode 100644 auth/iam_standalone.go create mode 100644 auth/iam_standalone_test.go create mode 100644 auth/object_lock_test.go create mode 100644 auth/signing_key_provider.go delete mode 100644 aws/LICENSE.txt delete mode 100644 aws/NOTICE.txt delete mode 100644 aws/README.md delete mode 100644 aws/internal/awstesting/unit/unit.go delete mode 100644 aws/signer/internal/v4/cache.go delete mode 100644 aws/signer/internal/v4/const.go delete mode 100644 aws/signer/internal/v4/header_rules.go delete mode 100644 aws/signer/internal/v4/headers.go delete mode 100644 aws/signer/internal/v4/headers_test.go delete mode 100644 aws/signer/internal/v4/hmac.go delete mode 100644 aws/signer/internal/v4/host.go delete mode 100644 aws/signer/internal/v4/scope.go delete mode 100644 aws/signer/internal/v4/time.go delete mode 100644 aws/signer/internal/v4/util.go delete mode 100644 aws/signer/internal/v4/util_test.go delete mode 100644 aws/signer/v4/functional_test.go delete mode 100644 aws/signer/v4/header_rules.go delete mode 100644 aws/signer/v4/v4.go delete mode 100644 aws/signer/v4/v4_test.go create mode 100755 genmtlscerts.sh create mode 100644 iamapi/internal/iamutil/identity.go create mode 100644 iamapi/private/errors.go create mode 100644 iamapi/private/handlers.go create mode 100644 iamapi/private/identity.go create mode 100644 iamapi/private/listener.go create mode 100644 iamapi/private/private_test.go create mode 100644 iamapi/private/server.go create mode 100644 iamapi/private/types.go rename {iamapi/policy => internal/condition}/condition.go (61%) rename {iamapi/policy => internal/condition}/condition_test.go (90%) create mode 100644 internal/netutil/clientcert.go rename s3api/utils/multi_listener_test.go => internal/netutil/multi_listener_full_test.go (97%) create mode 100644 internal/netutil/multi_listener_test.go create mode 100644 internal/sigv4auth/canonical.go create mode 100644 internal/sigv4auth/canonical_test.go create mode 100644 internal/sigv4auth/ctx.go create mode 100644 internal/sigv4auth/derive.go create mode 100644 internal/sigv4auth/derive_test.go create mode 100644 internal/sigv4auth/header_rules.go create mode 100644 internal/sigv4auth/request.go create mode 100755 runoidctests.sh delete mode 100644 s3api/utils/multi_listener.go create mode 100644 tests/integration/s3_iam_access_control.go create mode 100644 tests/integration/s3_iam_session_access_control.go create mode 100644 tests/integration/s3_iam_utils.go diff --git a/.github/workflows/functional-iam-oidc.yml b/.github/workflows/functional-iam-oidc.yml index 0c7a11d9..fefb5eeb 100644 --- a/.github/workflows/functional-iam-oidc.yml +++ b/.github/workflows/functional-iam-oidc.yml @@ -46,43 +46,6 @@ jobs: run: | make testbin - - name: Run GitHub OIDC live web-identity test + - name: Run GitHub OIDC live web-identity and s3 session tests run: | - set -Eeuo pipefail - - IAM_PID="" - cleanup() { - local status=$? - trap - EXIT - if [[ -n "$IAM_PID" ]] && kill -0 "$IAM_PID" 2>/dev/null; then - kill "$IAM_PID" 2>/dev/null || true - fi - if [[ -n "$IAM_PID" ]]; then - wait "$IAM_PID" 2>/dev/null || true - fi - exit "$status" - } - trap cleanup EXIT - - mkdir -p /tmp/iam-oidc - ./versitygw --health /healthz -p :7078 -a user -s pass iam --dir /tmp/iam-oidc & - IAM_PID=$! - - ready="" - for _ in {1..50}; do - if curl --fail --silent --max-time 1 http://127.0.0.1:7078/healthz >/dev/null 2>&1; then - ready=1 - break - fi - if ! kill -0 "$IAM_PID" 2>/dev/null; then - echo "IAM API server stopped before becoming ready" >&2 - exit 1 - fi - sleep 0.2 - done - if [[ -z "$ready" ]]; then - echo "timed out waiting for IAM API server" >&2 - exit 1 - fi - - ./versitygw test -a user -s pass -e http://127.0.0.1:7078 IAMAssumeRoleWithWebIdentity_github_oidc_live + ./runoidctests.sh diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index a4d63798..6e626665 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 './runiamtests.sh' -o -path './tests/*.sh' -o -path './tests/*/*.sh' \) -print0) + done < <(find . \( -path './runiamtests.sh' -o -path './genmtlscerts.sh' -o -path './runoidctests.sh' -o -path './tests/*.sh' -o -path './tests/*/*.sh' \) -print0) exit "$overall_rc" diff --git a/auth/access-control.go b/auth/access-control.go index 93d71743..1283b2f1 100644 --- a/auth/access-control.go +++ b/auth/access-control.go @@ -18,16 +18,28 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/url" "strings" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/s3err" ) -func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource string, opts AccessOptions) error { +func VerifyObjectCopyAccess(ctx fiber.Ctx, be backend.Backend, copySource string, opts AccessOptions) error { + // Verify destination bucket access first. VerifyAccess enforces the + // readonly gate before its own root/admin bypass, and that ordering + // must hold here too — readonly mode blocks writes for everyone, + // including root/admin, not just ordinary users. + if err := VerifyAccess(ctx, be, opts); err != nil { + return err + } + // Root/admin already cleared the destination check above; skip the + // source-bucket ACL lookup entirely for them, same as before. if opts.IsRoot { return nil } @@ -35,10 +47,6 @@ func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource return nil } - // Verify destination bucket access - if err := VerifyAccess(ctx, be, opts); err != nil { - return err - } // Verify source bucket access. // URL-decode the copy source before splitting so that clients which send // the bucket/key separator as "%2F" are handled correctly. @@ -53,7 +61,7 @@ func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource } // Get source bucket ACL - srcBucketACLBytes, err := be.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &srcBucket}) + srcBucketACLBytes, err := be.GetBucketAcl(ctx.RequestCtx(), &s3.GetBucketAclInput{Bucket: &srcBucket}) if err != nil { return err } @@ -71,6 +79,8 @@ func VerifyObjectCopyAccess(ctx context.Context, be backend.Backend, copySource Bucket: srcBucket, Object: srcObject, Actions: []Action{GetObjectAction}, + Iam: opts.Iam, + DisableACL: opts.DisableACL, }); err != nil { return err } @@ -89,50 +99,404 @@ type AccessOptions struct { Readonly bool IsPublicRequest bool DisableACL bool + Iam IAMService } -func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) error { +// VerifyAccess decides whether opts.Acc may perform opts.Actions against +// opts.Bucket/opts.Object, combining the bucket's own resource-based +// decision (policy, or ACL absent one) with an identity-based decision from +// opts.Iam when it implements PolicyEvaluator. An explicit Deny from either +// source denies the request outright, even when the other source would +// otherwise allow it; absent any explicit Deny, either source's Allow is +// independently sufficient; absent both, the request is denied. All three +// denial shapes are Code: AccessDenied, HTTP 403 — differing only in the +// dynamic Message text. +func VerifyAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions) error { + if err := verifyAccessGates(opts); err != nil || !authorizationApplies(opts) { + return err + } + + errs, err := objectsAccessErrors(ctx.RequestCtx(), be, opts, []string{opts.Object}, requestConditionContext(ctx)) + if err != nil { + return err + } + return errs[0] +} + +// VerifyObjectsAccess authorizes a multi-object delete — the parsed contents +// of a DeleteObjects request body, passed straight through. It answers, for +// every object independently, whether policy allows deleting it and whether +// an object lock protects it, in a single pass. DeleteObjects supports +// partial success — unlike every other write path — so a denial on one +// object must not affect any other: the caller sends only the objects that +// pass through to the backend, and reports the rest as per-object errors +// straight from the returned slice. +// +// Both halves are deliberately here rather than split across the caller: the +// per-object work needs one loop, not one loop per concern at a different +// layer, and the expensive parts of each half — the bucket policy, the +// batched identity-policy round trip, the bucket's lock configuration — are +// resolved once up front for the whole request. +// +// Every key is authorized against its own object ARN, the way real AWS does +// it: a policy granting s3:DeleteObject on "arn:aws:s3:::bucket/*" and +// nothing else deletes successfully. An object named with a VersionId is +// authorized against s3:DeleteObjectVersion instead of s3:DeleteObject, the +// same split the single-object DELETE path already makes: a policy granting +// only s3:DeleteObject denies the versioned deletes in the same batch that +// its keyed deletes succeed under, and the batch's response reports that +// denial on just that object, the rest unaffected. +// +// The returned slice has one entry per object: nil where that object may +// proceed, an AWS-shaped denial otherwise. opts.Object and opts.Actions are +// both ignored in favor of objects. The second return is non-nil only for a +// failure that isn't about any one object — readonly mode, or an error +// resolving the bucket's policy or lock configuration — and fails the whole +// request, matching what a hard failure did before this returned per-object +// results at all. +func VerifyObjectsAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions, objects []types.ObjectIdentifier, bypass BypassMode) ([]error, error) { + if err := verifyAccessGates(opts); err != nil { + return nil, err + } + if len(objects) == 0 { + return nil, nil + } + + rctx := ctx.RequestCtx() + condCtx := requestConditionContext(ctx) + + keys := make([]string, len(objects)) + for i, obj := range objects { + if obj.Key != nil { + keys[i] = *obj.Key + } + } + + errs := make([]error, len(objects)) + + // Authorization doesn't apply to root, admin, or a public-bucket + // request — errs stays all-nil from policy's perspective, and object + // locks still apply to them, so the loop below runs either way. + if authorizationApplies(opts) { + var plainIdx, versionedIdx []int + for i, obj := range objects { + if obj.VersionId != nil && *obj.VersionId != "" { + versionedIdx = append(versionedIdx, i) + } else { + plainIdx = append(plainIdx, i) + } + } + + if err := authorizeObjectSubset(rctx, be, opts, keys, plainIdx, DeleteObjectAction, errs, condCtx); err != nil { + return nil, err + } + if err := authorizeObjectSubset(rctx, be, opts, keys, versionedIdx, DeleteObjectVersionAction, errs, condCtx); err != nil { + return nil, err + } + } + + lockState, err := loadObjectLockState(rctx, be, opts.Bucket, false) + if err != nil { + return nil, err + } + if lockState.applies { + for i, obj := range objects { + if errs[i] != nil { + // Already denied by policy — no need to also resolve this + // object's lock state, and a lock error here would only + // overwrite the more specific policy denial. + continue + } + if err := lockState.checkObject(rctx, be, opts.Iam, opts.Acc, opts.Bucket, obj, bypass, opts.IsPublicRequest, condCtx); err != nil { + errs[i] = err + } + } + } + + return errs, nil +} + +// authorizeObjectSubset runs objectsAccessErrors for the objects at idx (a +// subset of keys, given by original index) against a single action, and +// scatters the results back into errs at their original positions. Splitting +// DeleteObjects' batch into one group per action this way keeps the +// round-trip count at one per distinct action in the batch — normally one or +// two — rather than one per object. +func authorizeObjectSubset(ctx context.Context, be backend.Backend, opts AccessOptions, keys []string, idx []int, action Action, errs []error, condCtx map[string][]string) error { + if len(idx) == 0 { + return nil + } + + subKeys := make([]string, len(idx)) + for i, origIdx := range idx { + subKeys[i] = keys[origIdx] + } + + subOpts := opts + subOpts.Actions = []Action{action} + subErrs, err := objectsAccessErrors(ctx, be, subOpts, subKeys, condCtx) + if err != nil { + return err + } + for i, origIdx := range idx { + errs[origIdx] = subErrs[i] + } + return nil +} + +// verifyAccessGates applies the checks that depend on gateway configuration +// rather than on the caller's policies. Readonly mode blocks writes for +// everyone, root and admin included, which is why it runs before any bypass. +func verifyAccessGates(opts AccessOptions) error { if opts.Readonly { if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp { return s3err.GetAPIError(s3err.ErrAccessDenied) } } - // Skip the access check for public bucket requests - if opts.IsPublicRequest { - return nil + return nil +} + +// authorizationApplies reports whether policy/ACL evaluation is meaningful +// for this caller at all. It is not for an anonymous request to a public +// bucket (already authorized by the public-access check) nor for root/admin +// (who bypass policy entirely — though not object locks). +func authorizationApplies(opts AccessOptions) bool { + return !opts.IsPublicRequest && !opts.IsRoot && opts.Acc.Role != RoleAdmin +} + +// objectsAccessErrors evaluates every key against the bucket's resource +// policy (or ACL) and the caller's identity policy, returning one result per +// key: nil where the key is authorized, and the AWS-shaped denial otherwise. +// The returned slice always has one entry per key. +// +// The keys are evaluated as one batch, not one VerifyAccess call each: the +// bucket policy is fetched once, and the identity policy is evaluated for +// every key in a single round trip to the IAM service. A per-key loop would +// cost a backend call and a network round trip per object, and DeleteObjects +// accepts up to 1000 of them. +func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOptions, keys []string, condCtx map[string][]string) ([]error, error) { + resourceDecisions, err := verifyResourceAccess(ctx, be, opts, keys, condCtx) + if err != nil { + return nil, err } - if opts.IsRoot { - return nil + + errs := make([]error, len(keys)) + + // An explicit deny from the bucket policy wins outright, whatever the + // IAM backend is, so a request carrying one needs no identity policy at + // all — which also saves the standalone IAM service round trip. Only the + // first denied key is recorded: the request fails there regardless of + // what the rest would have evaluated to. + for i, rd := range resourceDecisions { + if rd.Decision == policyDecisionDeny { + errs[i] = s3err.GetExplicitDenyAccessErr(opts.Acc.Access, string(rd.Action), objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey), "a resource-based policy") + return errs, nil + } } - if opts.Acc.Role == RoleAdmin { - return nil + + pe, hasPolicyEvaluator := opts.Iam.(PolicyEvaluator) + if !hasPolicyEvaluator { + // No identity-policy layer exists for this backend at all: preserve + // today's exact behavior and generic message, unconditionally, for + // every internal/LDAP/Vault/IPA/S3-IAM deployment. + for i, rd := range resourceDecisions { + if rd.Decision != policyDecisionAllow { + errs[i] = s3err.GetAPIError(s3err.ErrAccessDenied) + } + } + return errs, nil } + identity, err := identityPolicyDecisions(pe, opts, keys, be.NormalizeObjectKey, condCtx) + if err != nil { + return nil, err + } + + principal := identity.PrincipalArn + if principal == "" { + principal = opts.Acc.Access + } + + for i := range keys { + resourceArn := objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey) + + if identity.Decisions[i].Decision == policyDecisionDeny { + errs[i] = s3err.GetExplicitDenyAccessErr(principal, string(identity.Decisions[i].Action), resourceArn, "an identity-based policy") + continue + } + if identity.HasSessionPolicy && identity.SessionDecisions[i].Decision == policyDecisionDeny { + errs[i] = s3err.GetExplicitDenyAccessErr(principal, string(identity.SessionDecisions[i].Action), resourceArn, "an identity-based policy") + continue + } + + granted := resourceDecisions[i].Decision == policyDecisionAllow || + identity.Decisions[i].Decision == policyDecisionAllow + + // A session policy filters everything the session can do — including + // what the bucket policy granted it, not just what the role's own + // policies did. Confirmed against real AWS: a role with no identity + // policy at all, a bucket policy granting it both s3:GetObject and + // s3:PutObject, and a session policy allowing only s3:GetObject + // yields a successful Get and a denied Put. + if identity.HasSessionPolicy && identity.SessionDecisions[i].Decision != policyDecisionAllow { + granted = false + } + if granted { + continue + } + + blamedAction := resourceDecisions[i].Action + if blamedAction == "" { + blamedAction = identity.Decisions[i].Action + } + if blamedAction == "" && identity.HasSessionPolicy { + blamedAction = identity.SessionDecisions[i].Action + } + errs[i] = s3err.GetImplicitDenyAccessErr(principal, string(blamedAction), resourceArn) + } + + return errs, nil +} + +// decisionForResource is one resource's tri-state decision plus, for +// Deny/NoMatch, the specific action responsible — so the caller can build an +// AWS-shaped message naming it. +type decisionForResource struct { + Decision policyDecision + Action Action +} + +// verifyResourceAccess checks the bucket's own policy or, absent one, ACL, +// for each object key, returning one decision per key. The bucket policy is +// fetched once regardless of how many keys there are. ACL evaluation can +// only ever produce Allow/NoMatch — ACLs have no concept of an explicit +// deny — and applies to the whole bucket, so every key shares its verdict. +func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOptions, objects []string, condCtx map[string][]string) ([]decisionForResource, error) { + decisions := make([]decisionForResource, len(objects)) + policy, policyErr := be.GetBucketPolicy(ctx, opts.Bucket) if policyErr != nil { if !errors.Is(policyErr, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { - return policyErr + return nil, policyErr } - } else { - return VerifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, opts.Object, be.NormalizeObjectKey, opts.Actions...) + + decision := policyDecisionAllow + if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission, opts.DisableACL); err != nil { + decision = policyDecisionNoMatch + } + for i := range decisions { + decisions[i] = decisionForResource{Decision: decision} + } + return decisions, nil } - if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission, opts.DisableACL); err != nil { - return err + for i, object := range objects { + decision, action, err := verifyBucketPolicy(policy, opts.Acc.Access, opts.Bucket, object, condCtx, be.NormalizeObjectKey, opts.Actions...) + if err != nil { + return nil, err + } + decisions[i] = decisionForResource{Decision: decision, Action: action} + } + return decisions, nil +} + +// identityPolicyDecisions evaluates every action in opts.Actions against +// every object key, all in a single request, and aggregates each key's +// actions with the same precedence bucketPolicyDecision uses for a bucket +// policy: a Deny on any action wins immediately; otherwise Allow only if +// every action has a matching Allow; otherwise NoMatch, paired with the +// first action that lacked one. +// +// It returns one decision per key, plus the resolved principal ARN, which is +// shared across the whole batch since one call always evaluates a single +// identity. +func identityPolicyDecisions(pe PolicyEvaluator, opts AccessOptions, objects []string, normalizeObjectKey objectKeyNormalizer, condition map[string][]string) (identityDecisions, error) { + resources := make([]string, len(objects)) + for i, object := range objects { + resources[i] = objectPolicyArn(opts.Bucket, object, normalizeObjectKey) } - return nil + eval, err := pe.EvaluatePolicy(opts.Acc.Access, opts.Acc.SessionToken, opts.Actions, resources, condition) + if err != nil { + return identityDecisions{}, err + } + + decisions, err := aggregateActionDecisions(eval.Decisions, resources, opts.Actions) + if err != nil { + return identityDecisions{}, err + } + + result := identityDecisions{Decisions: decisions, PrincipalArn: eval.PrincipalArn} + if eval.HasSessionPolicy { + sessionDecisions, err := aggregateActionDecisions(eval.SessionDecisions, resources, opts.Actions) + if err != nil { + return identityDecisions{}, err + } + result.HasSessionPolicy = true + result.SessionDecisions = sessionDecisions + } + return result, nil +} + +// identityDecisions is identityPolicyDecisions' result: one aggregated +// decision per object from the caller's identity policies, the same from its +// session policy when it has one, and the resolved principal ARN. +type identityDecisions struct { + Decisions []decisionForResource + SessionDecisions []decisionForResource + HasSessionPolicy bool + PrincipalArn string +} + +// aggregateActionDecisions collapses each resource's per-action decisions +// into one, using the same precedence bucketPolicyDecision uses: a Deny on +// any action wins immediately; otherwise Allow only if every action has a +// matching Allow; otherwise NoMatch, paired with the first action that +// lacked one. +func aggregateActionDecisions(matrix [][]policyDecision, resources []string, actions []Action) ([]decisionForResource, error) { + if len(matrix) != len(resources) { + // A protocol mismatch between the gateway and IAM service builds — + // fail closed rather than authorizing a key nobody evaluated. + return nil, fmt.Errorf("evaluate policy returned %d resource decisions for %d resources", len(matrix), len(resources)) + } + + results := make([]decisionForResource, len(resources)) + for i, perAction := range matrix { + if len(perAction) != len(actions) { + return nil, fmt.Errorf("evaluate policy returned %d action decisions for %d actions", len(perAction), len(actions)) + } + + result := decisionForResource{Decision: policyDecisionAllow} + for j, decision := range perAction { + if decision == policyDecisionDeny { + result = decisionForResource{Decision: policyDecisionDeny, Action: actions[j]} + break + } + if decision == policyDecisionNoMatch && result.Decision != policyDecisionNoMatch { + result.Decision = policyDecisionNoMatch + result.Action = actions[j] + } + } + results[i] = result + } + return results, nil +} + +// objectPolicyArn builds the ARN a policy statement is matched against for +// one bucket/object pair — the bucket's own ARN when object is empty. +func objectPolicyArn(bucket, object string, normalizeObjectKey objectKeyNormalizer) string { + return ResourceArnPrefix + makePolicyResource(bucket, object, normalizeObjectKey) } // VerifyPublicAccess checks if the bucket is publically accessible by ACL or Policy -func VerifyPublicAccess(ctx context.Context, be backend.Backend, action Action, permission Permission, bucket, object string) error { +func VerifyPublicAccess(ctx fiber.Ctx, be backend.Backend, action Action, permission Permission, bucket, object string) error { // ACL disabled - policy, err := be.GetBucketPolicy(ctx, bucket) + policy, err := be.GetBucketPolicy(ctx.RequestCtx(), bucket) if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { return err } if err == nil { - err = VerifyPublicBucketPolicy(policy, bucket, object, be.NormalizeObjectKey, action) + err = VerifyPublicBucketPolicy(policy, bucket, object, requestConditionContext(ctx), be.NormalizeObjectKey, action) if errors.Is(err, errExplicitDeny) { // Explicit public-policy Deny has higher precedence than any // public ACL grant, so do not continue to ACL fallback. @@ -160,7 +524,7 @@ func VerifyPublicAccess(ctx context.Context, be backend.Backend, action Action, return s3err.GetAPIError(s3err.ErrAccessDenied) } - err = VerifyPublicBucketACL(ctx, be, bucket, action, permission) + err = VerifyPublicBucketACL(ctx.RequestCtx(), be, bucket, action, permission) if err != nil { return s3err.GetAPIError(s3err.ErrAccessDenied) } @@ -168,6 +532,67 @@ func VerifyPublicAccess(ctx context.Context, be backend.Backend, action Action, return nil } +// VerifyCreateBucketAccess decides whether acc may create a bucket named +// bucket. Unlike VerifyAccess, the bucket doesn't exist yet at this point, +// so there is no bucket policy or ACL to consult — root/admin always +// bypass, and otherwise authorization comes from whichever mechanism the +// configured iam backend actually supports: for backends that implement +// PolicyEvaluator (currently only the standalone IAM service client), an +// identity-policy Allow for s3:CreateBucket grants access, exactly like any +// other IAM-policy-gated action; the legacy userplus-role bypass applies +// only to backends with no such policy layer (internal/LDAP/Vault/IPA/S3-IAM), +// since those have no other way to grant a plain "user" account this +// permission. +func VerifyCreateBucketAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Account, bucket string) error { + if isRoot || acc.Role == RoleAdmin { + return nil + } + + pe, hasPolicyEvaluator := iam.(PolicyEvaluator) + if !hasPolicyEvaluator { + if acc.Role == RoleUserPlus { + return nil + } + return s3err.GetAPIError(s3err.ErrAccessDenied) + } + + resourceArn := ResourceArnPrefix + bucket + identity, err := identityPolicyDecisions(pe, AccessOptions{ + Acc: acc, + Bucket: bucket, + Actions: []Action{CreateBucketAction}, + }, []string{""}, nil, requestConditionContext(ctx)) + if err != nil { + return err + } + + principal := identity.PrincipalArn + if principal == "" { + principal = acc.Access + } + + // A session policy narrows what the session may do; there is no resource + // policy for a bucket that does not exist yet, so the two decisions + // simply intersect here. + decision := identity.Decisions[0].Decision + if identity.HasSessionPolicy { + switch sd := identity.SessionDecisions[0].Decision; { + case sd == policyDecisionDeny: + decision = policyDecisionDeny + case sd != policyDecisionAllow && decision == policyDecisionAllow: + decision = policyDecisionNoMatch + } + } + + switch decision { + case policyDecisionDeny: + return s3err.GetExplicitDenyAccessErr(principal, string(CreateBucketAction), resourceArn, "an identity-based policy") + case policyDecisionAllow: + return nil + } + return s3err.GetImplicitDenyAccessErr(principal, string(CreateBucketAction), resourceArn) +} + func IsAdminOrOwner(acct Account, isRoot bool, acl ACL) error { // Owner check if acct.Access == acl.Owner { diff --git a/auth/access-control_test.go b/auth/access-control_test.go index 14de895b..248566ab 100644 --- a/auth/access-control_test.go +++ b/auth/access-control_test.go @@ -18,16 +18,32 @@ import ( "context" "encoding/json" "errors" + "net/http" "path/filepath" "testing" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" + "github.com/valyala/fasthttp" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/s3err" ) +// testFiberCtx returns a fiber.Ctx for tests to pass to functions that read +// request-derived data (e.g. the condition context) off it, released +// automatically when the test ends. +func testFiberCtx(t *testing.T) fiber.Ctx { + t.Helper() + app := fiber.New() + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + t.Cleanup(func() { + app.ReleaseCtx(ctx) + }) + return ctx +} + // noBucketPolicyBackend is a test stub that returns ErrNoSuchBucketPolicy for // GetBucketPolicy and serves a configurable ACL for GetBucketAcl. type noBucketPolicyBackend struct { @@ -94,6 +110,264 @@ func publicReadACL() ACL { } } +// mockPolicyEvaluator implements IAMService (via the embedded +// IAMServiceSingle, whose methods are never exercised here) and +// PolicyEvaluator, recording every EvaluatePolicy call so tests can assert +// both the outcome and exactly what VerifyAccess asked it to evaluate. +type mockPolicyEvaluator struct { + IAMService + decision policyDecision + principalArn string + err error + calls []evaluatePolicyCall +} + +type evaluatePolicyCall struct { + access, sessionToken string + resources []string + actions []Action + condition map[string][]string +} + +func (m *mockPolicyEvaluator) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) { + m.calls = append(m.calls, evaluatePolicyCall{ + access: access, + sessionToken: sessionToken, + actions: actions, + resources: resources, + condition: condition, + }) + decisions := make([][]policyDecision, len(resources)) + for i := range resources { + decisions[i] = make([]policyDecision, len(actions)) + for j := range actions { + decisions[i][j] = m.decision + } + } + return PolicyEvaluation{Decisions: decisions, PrincipalArn: m.principalArn}, m.err +} + +func newMockPolicyEvaluator(decision policyDecision) *mockPolicyEvaluator { + return &mockPolicyEvaluator{IAMService: NewIAMServiceSingle(Account{}), decision: decision} +} + +// requireAccessDeniedAPIError asserts err is an s3err.APIError with the AWS +// AccessDenied shape (Code, HTTP 403) and returns it for the caller to +// inspect the dynamic Description text further. +func requireAccessDeniedAPIError(t *testing.T, err error) s3err.APIError { + t.Helper() + apiErr, ok := err.(s3err.APIError) + if !ok { + t.Fatalf("err = %#v (%T), want s3err.APIError", err, err) + } + assert.Equal(t, "AccessDenied", apiErr.Code) + assert.Equal(t, http.StatusForbidden, apiErr.HTTPStatusCode) + return apiErr +} + +// TestVerifyAccess_ResourceAllowStillChecksIdentityForExplicitDeny confirms +// the fix for the core bug: a bucket policy Allow used to short-circuit +// before the identity-policy layer was ever consulted, so an identity +// policy's explicit Deny was silently ignored whenever the bucket policy +// already allowed. Now the identity policy is always consulted too — here +// it has no opinion (NoMatch), so the bucket policy's Allow still stands, +// but EvaluatePolicy must actually have been called for that to be a real +// verdict rather than a skipped check. +func TestVerifyAccess_ResourceAllowStillChecksIdentityForExplicitDeny(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + Iam: pe, + }) + + assert.NoError(t, err) + assert.Len(t, pe.calls, 1, "EvaluatePolicy must now be called even when the resource-level check already allows, so an explicit identity-policy Deny can still override it") +} + +// TestVerifyAccess_IdentityExplicitDenyOverridesResourceAllow is the +// explicit-deny-wins fix: a bucket policy Allow does not save a request the +// caller's own identity policy explicitly denies. The Message names the +// resolved principal ARN and calls out "an identity-based policy" — +// matching what real AWS returns for this case. +func TestVerifyAccess_IdentityExplicitDenyOverridesResourceAllow(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionDeny) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + Iam: pe, + }) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "s3:GetObject") + assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy") +} + +// TestVerifyAccess_ResourceExplicitDenyOverridesIdentityAllow is the +// reverse case: an identity policy Allow does not save a request the +// bucket policy explicitly denies. The resource-level Deny short-circuits +// before the identity policy is even consulted (it can't change the +// outcome, and it saves the standalone IAM service round trip), and the +// Message calls out "a resource-based policy". +func TestVerifyAccess_ResourceExplicitDenyOverridesIdentityAllow(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Deny", + "Principal": "testuser", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + Iam: pe, + }) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "testuser") + assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy") + assert.Empty(t, pe.calls, "a resource-level explicit deny should short-circuit before consulting the identity policy") +} + +// TestVerifyAccess_IdentityPolicyAllowsWhenResourceDenies is the core +// same-account fix: a private bucket with no ACL grant and no bucket policy +// still allows access when the caller's IAM identity policy grants it — +// matching real AWS, where a bucket policy is only *required* for +// cross-account access; within the same account (this gateway is always +// single-account) an identity-based Allow alone is sufficient. +func TestVerifyAccess_IdentityPolicyAllowsWhenResourceDenies(t *testing.T) { + be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}} + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + AclPermission: PermissionRead, + Iam: pe, + }) + + assert.NoError(t, err) + assert.Len(t, pe.calls, 1) + assert.Equal(t, []string{"arn:aws:s3:::bucket/key.txt"}, pe.calls[0].resources) + assert.Equal(t, []Action{GetObjectAction}, pe.calls[0].actions) + assert.Equal(t, "testuser", pe.calls[0].access) +} + +// TestVerifyAccess_DeniedWhenNeitherResourceNorIdentityPolicyAllows confirms +// access is denied — with the AWS-shaped implicit-deny message, since a +// PolicyEvaluator is configured — when neither the resource-level check +// (ACL owned by someone else, no bucket policy) nor the identity policy has +// any opinion at all (NoMatch, not an explicit Deny from either side). It +// also pins that the message names the resolved principal ARN, not the +// access key — matching real AWS's implicit-deny message shape (previously +// this fell back to the access key even when the PolicyEvaluator resolved +// an ARN, since identityPolicyDecision only threaded PrincipalArn through +// on its Deny branch). +func TestVerifyAccess_DeniedWhenNeitherResourceNorIdentityPolicyAllows(t *testing.T) { + be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}} + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + AclPermission: PermissionRead, + Iam: pe, + }) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:GetObject action") + assert.Len(t, pe.calls, 1) +} + +// TestVerifyAccess_NoPolicyEvaluatorIsANoOp confirms backends that don't +// implement PolicyEvaluator (every backend except the standalone IAM +// client) are entirely unaffected by this layer — backward compatibility +// via the type assertion, not a config flag. +func TestVerifyAccess_NoPolicyEvaluatorIsANoOp(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + Iam: NewIAMServiceSingle(Account{}), + }) + + assert.NoError(t, err) +} + +// TestVerifyAccess_NoPolicyEvaluatorDeniedKeepsGenericMessage pins that, +// with no PolicyEvaluator configured, a denied request's error stays +// byte-for-byte today's generic message — the dynamic AWS-shaped messages +// above only ever appear once a PolicyEvaluator is actually in play, so +// every internal/LDAP/Vault/IPA/S3-IAM deployment sees no message change +// from this fix at all. +func TestVerifyAccess_NoPolicyEvaluatorDeniedKeepsGenericMessage(t *testing.T) { + be := noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}} + + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + Object: "key.txt", + Actions: []Action{GetObjectAction}, + AclPermission: PermissionRead, + Iam: NewIAMServiceSingle(Account{}), + }) + + assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) +} + func TestVerifyAccess_NormalizesObjectKeyBeforePolicyMatch(t *testing.T) { be := &publicBucketPolicyBackend{ normalizeFn: testNormalizeObjectKey, @@ -107,7 +381,7 @@ func TestVerifyAccess_NormalizesObjectKeyBeforePolicyMatch(t *testing.T) { }`), } - err := VerifyAccess(context.Background(), be, AccessOptions{ + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ Acc: Account{Access: "testuser", Role: RoleUser}, Bucket: "bucket", Object: "public/../private.txt", @@ -131,7 +405,7 @@ func TestVerifyAccess_NormalizesPolicyResourceBeforeMatch(t *testing.T) { }`), } - err := VerifyAccess(context.Background(), be, AccessOptions{ + err := VerifyAccess(testFiberCtx(t), be, AccessOptions{ Acc: Account{Access: "testuser", Role: RoleUser}, Bucket: "bucket", Object: "private.txt", @@ -154,7 +428,7 @@ func TestVerifyPublicAccess_PublicPolicyDenyStopsACLFallback(t *testing.T) { acl: publicReadACL(), } - err := VerifyPublicAccess(context.Background(), be, GetObjectAction, PermissionRead, "bucket", "private/secret.txt") + err := VerifyPublicAccess(testFiberCtx(t), be, GetObjectAction, PermissionRead, "bucket", "private/secret.txt") assert.Error(t, err) assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied))) @@ -174,7 +448,7 @@ func TestVerifyPublicAccess_PublicPolicyNoMatchFallsBackToACL(t *testing.T) { acl: publicReadACL(), } - err := VerifyPublicAccess(context.Background(), be, GetObjectAction, PermissionRead, "bucket", "public/object.txt") + err := VerifyPublicAccess(testFiberCtx(t), be, GetObjectAction, PermissionRead, "bucket", "public/object.txt") assert.NoError(t, err) assert.Equal(t, 1, be.aclCalls) @@ -194,7 +468,7 @@ func TestVerifyPublicAccess_NormalizedDenyStopsACLFallback(t *testing.T) { acl: publicReadACL(), } - err := VerifyPublicAccess(context.Background(), be, GetObjectAction, PermissionRead, "bucket", "public/../private/secret.txt") + err := VerifyPublicAccess(testFiberCtx(t), be, GetObjectAction, PermissionRead, "bucket", "public/../private/secret.txt") assert.Error(t, err) assert.True(t, errors.Is(err, s3err.GetAPIError(s3err.ErrAccessDenied))) @@ -204,21 +478,15 @@ func TestVerifyPublicAccess_NormalizedDenyStopsACLFallback(t *testing.T) { func TestVerifyObjectCopyAccess_URLEncodedSlashSeparator(t *testing.T) { const testUser = "testuser" - // Source bucket ACL: grants READ to testUser. - srcAcl := ACL{ - Owner: "owner", - Grantees: []Grantee{ - { - Access: testUser, - Permission: PermissionRead, - Type: types.TypeCanonicalUser, - }, - }, - } + // Source and destination bucket ACLs: testUser owns both. opts sets + // DisableACL, which now applies uniformly to the source-bucket check + // VerifyObjectCopyAccess performs internally as well as the + // destination's, collapsing both to an owner-only check — a grantee + // entry alone (without ownership) would no longer be sufficient. + srcAcl := ACL{Owner: testUser} be := noBucketPolicyBackend{srcAcl: srcAcl} - // Destination bucket ACL: testUser is the owner (DisableACL=true path). opts := AccessOptions{ Acl: ACL{Owner: testUser}, AclPermission: PermissionWrite, @@ -249,7 +517,7 @@ func TestVerifyObjectCopyAccess_URLEncodedSlashSeparator(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := VerifyObjectCopyAccess(context.Background(), be, tt.copySource, opts) + err := VerifyObjectCopyAccess(testFiberCtx(t), be, tt.copySource, opts) assert.NoError(t, err, "should accept %%2F as the bucket/key separator in x-amz-copy-source") }) @@ -259,16 +527,10 @@ func TestVerifyObjectCopyAccess_URLEncodedSlashSeparator(t *testing.T) { func TestVerifyObjectCopyAccess_LiteralSlashSeparator(t *testing.T) { const testUser = "testuser" - srcAcl := ACL{ - Owner: "owner", - Grantees: []Grantee{ - { - Access: testUser, - Permission: PermissionRead, - Type: types.TypeCanonicalUser, - }, - }, - } + // testUser owns both source and destination buckets — see the comment + // in TestVerifyObjectCopyAccess_URLEncodedSlashSeparator on why + // DisableACL requires ownership here rather than a grantee entry. + srcAcl := ACL{Owner: testUser} be := noBucketPolicyBackend{srcAcl: srcAcl} @@ -282,6 +544,180 @@ func TestVerifyObjectCopyAccess_LiteralSlashSeparator(t *testing.T) { DisableACL: true, } - err := VerifyObjectCopyAccess(context.Background(), be, "src-bucket/src-key", opts) + err := VerifyObjectCopyAccess(testFiberCtx(t), be, "src-bucket/src-key", opts) assert.NoError(t, err, "literal slash separator should work") } + +// TestVerifyCreateBucketAccess_RootAndAdminBypass confirms root and admin +// accounts may always create a bucket, with no iam backend consulted at +// all — CreateBucket has no existing bucket to check a policy or ACL +// against, so this bypass (unlike VerifyAccess's, which still runs the +// resource-policy check first) is the entire decision. +func TestVerifyCreateBucketAccess_RootAndAdminBypass(t *testing.T) { + err := VerifyCreateBucketAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), true, Account{Access: "testuser", Role: RoleUser}, "bucket") + assert.NoError(t, err) + + err = VerifyCreateBucketAccess(testFiberCtx(t), NewIAMServiceSingle(Account{}), false, Account{Access: "testuser", Role: RoleAdmin}, "bucket") + assert.NoError(t, err) +} + +// TestVerifyCreateBucketAccess_NoPolicyEvaluatorUsesLegacyRoleGate confirms +// that for every backend without an identity-policy layer (internal, LDAP, +// Vault, IPA, S3-IAM) bucket creation keeps working exactly as it always +// has: userplus is allowed, a plain user is denied with the generic +// AccessDenied error, and EvaluatePolicy is never a factor since these +// backends don't implement PolicyEvaluator at all. +func TestVerifyCreateBucketAccess_NoPolicyEvaluatorUsesLegacyRoleGate(t *testing.T) { + iam := NewIAMServiceSingle(Account{}) + + err := VerifyCreateBucketAccess(testFiberCtx(t), iam, false, Account{Access: "testuser", Role: RoleUserPlus}, "bucket") + assert.NoError(t, err) + + err = VerifyCreateBucketAccess(testFiberCtx(t), iam, false, Account{Access: "testuser", Role: RoleUser}, "bucket") + assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied), err) +} + +// TestVerifyCreateBucketAccess_PolicyEvaluatorAllow confirms the core fix: +// a standalone-IAM-service user, who is always Role RoleUser regardless of +// their attached IAM policy, can create a bucket when that policy grants +// s3:CreateBucket — the identity-policy Allow is what grants access, not +// the role. +func TestVerifyCreateBucketAccess_PolicyEvaluatorAllow(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket") + + assert.NoError(t, err) + assert.Len(t, pe.calls, 1) + assert.Equal(t, "testuser", pe.calls[0].access) + assert.Equal(t, []string{"arn:aws:s3:::bucket"}, pe.calls[0].resources) + assert.Equal(t, []Action{CreateBucketAction}, pe.calls[0].actions) +} + +// TestVerifyCreateBucketAccess_PolicyEvaluatorNoMatchDenies confirms a +// standalone-IAM-service user with no policy granting s3:CreateBucket is +// denied — with the AWS-shaped implicit-deny message — even though the +// legacy role gate alone would have denied them anyway; this pins that the +// policy layer, not the role, is now what's actually being asked. +func TestVerifyCreateBucketAccess_PolicyEvaluatorNoMatchDenies(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket") + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "s3:CreateBucket") + assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:CreateBucket action") +} + +// TestVerifyCreateBucketAccess_PolicyEvaluatorExplicitDenyWins confirms an +// explicit Deny in the identity policy is reported with the AWS-shaped +// explicit-deny message, naming the resolved principal ARN when the +// PolicyEvaluator reports one. +func TestVerifyCreateBucketAccess_PolicyEvaluatorExplicitDenyWins(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionDeny) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUser}, "bucket") + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "s3:CreateBucket") + assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy") +} + +// TestVerifyCreateBucketAccess_PolicyEvaluatorIgnoresUserPlus confirms the +// legacy userplus bypass does not leak into the PolicyEvaluator path: once +// a backend implements identity-policy evaluation, that policy is the sole +// gate for non-admin accounts, matching the standalone IAM service's real +// behavior (its accounts are always Role RoleUser, never RoleUserPlus, so +// this also documents why the bypass would be a no-op there in practice). +func TestVerifyCreateBucketAccess_PolicyEvaluatorIgnoresUserPlus(t *testing.T) { + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + + err := VerifyCreateBucketAccess(testFiberCtx(t), pe, false, Account{Access: "testuser", Role: RoleUserPlus}, "bucket") + + assert.Error(t, err) + assert.Len(t, pe.calls, 1, "EvaluatePolicy must be consulted even for a userplus account once a PolicyEvaluator is configured") +} + +// noObjectLockBackend answers "no lock configuration" for +// GetObjectLockConfiguration, so VerifyObjectsAccess's lock check is a no-op +// and only the policy/ACL half of the result is under test — matching what +// loadObjectLockState treats as "object lock was never configured on this +// bucket", not the BackendUnsupported stub's ErrNotImplemented, which would +// otherwise fail the whole request before either object was authorized. +type noObjectLockBackend struct { + noBucketPolicyBackend +} + +func (b noObjectLockBackend) GetObjectLockConfiguration(_ context.Context, _ string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound) +} + +// actionSplitPolicyEvaluator denies exactly one action and allows every +// other, recording each EvaluatePolicy call it receives — for asserting not +// just the outcome but that DeleteObjects' mixed batch was split into one +// call per action rather than evaluated as a single undifferentiated batch. +type actionSplitPolicyEvaluator struct { + IAMService + denyAction Action + calls []evaluatePolicyCall +} + +func (m *actionSplitPolicyEvaluator) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) { + m.calls = append(m.calls, evaluatePolicyCall{ + access: access, + sessionToken: sessionToken, + actions: actions, + resources: resources, + condition: condition, + }) + decisions := make([][]policyDecision, len(resources)) + for i := range resources { + decisions[i] = make([]policyDecision, len(actions)) + for j, a := range actions { + if a == m.denyAction { + decisions[i][j] = policyDecisionNoMatch + } else { + decisions[i][j] = policyDecisionAllow + } + } + } + return PolicyEvaluation{Decisions: decisions}, nil +} + +func TestVerifyObjectsAccess_VersionedDeleteNeedsSeparatePermission(t *testing.T) { + be := noObjectLockBackend{noBucketPolicyBackend{srcAcl: ACL{Owner: "someone-else"}}} + pe := &actionSplitPolicyEvaluator{denyAction: DeleteObjectVersionAction} + + objects := []types.ObjectIdentifier{ + {Key: strPtr("plain.txt")}, + {Key: strPtr("versioned.txt"), VersionId: strPtr("v1")}, + } + + errs, err := VerifyObjectsAccess(testFiberCtx(t), be, AccessOptions{ + Acc: Account{Access: "testuser", Role: RoleUser}, + Bucket: "bucket", + AclPermission: PermissionWrite, + Iam: pe, + }, objects, BypassNone) + + assert.NoError(t, err) + if assert.Len(t, errs, 2) { + assert.NoError(t, errs[0], "the keyed delete should be authorized against s3:DeleteObject, which is allowed") + apiErr := requireAccessDeniedAPIError(t, errs[1]) + assert.Contains(t, apiErr.Description, "s3:DeleteObjectVersion") + assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:DeleteObjectVersion action") + } + + if assert.Len(t, pe.calls, 2, "the batch should split into one EvaluatePolicy call per distinct action") { + assert.Equal(t, []Action{DeleteObjectAction}, pe.calls[0].actions) + assert.Equal(t, []string{"arn:aws:s3:::bucket/plain.txt"}, pe.calls[0].resources) + assert.Equal(t, []Action{DeleteObjectVersionAction}, pe.calls[1].actions) + assert.Equal(t, []string{"arn:aws:s3:::bucket/versioned.txt"}, pe.calls[1].resources) + } +} + +func strPtr(s string) *string { return &s } diff --git a/auth/acl.go b/auth/acl.go index 807efbc7..60dfc6b3 100644 --- a/auth/acl.go +++ b/auth/acl.go @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "encoding/xml" - "errors" "fmt" "strings" @@ -361,7 +360,7 @@ func UpdateACL(input *PutBucketAclInput, acl ACL, iam IAMService) ([]byte, error } // Check if the specified accounts exist - accList, err := CheckIfAccountsExist(accs, iam) + accList, err := iam.ResolveAccounts(accs) if err != nil { return nil, err } @@ -380,25 +379,6 @@ func UpdateACL(input *PutBucketAclInput, acl ACL, iam IAMService) ([]byte, error return result, nil } -func CheckIfAccountsExist(accs []string, iam IAMService) ([]string, error) { - result := []string{} - - for _, acc := range accs { - _, err := iam.GetUserAccount(acc) - if err != nil { - if err == ErrNoSuchUser || err == s3err.GetAPIError(s3err.ErrAdminUserNotFound) { - result = append(result, acc) - continue - } - if errors.Is(err, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)) { - return nil, err - } - return nil, fmt.Errorf("check user account: %w", err) - } - } - return result, nil -} - func splitUnique(s, divider string) []string { elements := strings.Split(s, divider) uniqueElements := make(map[string]bool) diff --git a/auth/bucket_policy.go b/auth/bucket_policy.go index 7c463ad5..5f0d2041 100644 --- a/auth/bucket_policy.go +++ b/auth/bucket_policy.go @@ -20,15 +20,19 @@ import ( "fmt" "net/http" + "github.com/versity/versitygw/internal/condition" "github.com/versity/versitygw/s3err" ) var errAccessDenied = errors.New("access denied") var errExplicitDeny = errors.New("explicit deny") -// policyDecision preserves the difference between "not allowed" and "denied". -// Public bucket authorization needs that distinction so no-match can fall back -// to ACLs while explicit Deny cannot. +// policyDecision preserves the difference between "not allowed" and +// "denied". Public bucket authorization needs that distinction so no-match +// can fall back to ACLs while explicit Deny cannot; VerifyAccess needs it to +// combine a bucket policy's decision with an identity policy's own — an +// explicit Deny from either source must override an Allow from the other, +// which a plain bool can't express. type policyDecision int const ( @@ -44,15 +48,18 @@ func (p policyErr) Error() string { } const ( - policyErrResourceMismatch = policyErr("Action does not apply to any resource(s) in statement") - policyErrInvalidResource = policyErr("Policy has invalid resource") - policyErrInvalidPrincipal = policyErr("Invalid principal in policy") - policyErrInvalidAction = policyErr("Policy has invalid action") - policyErrInvalidPolicy = policyErr("This policy contains invalid Json") - policyErrInvalidFirstChar = policyErr("Policies must be valid JSON and the first byte must be '{'") - policyErrEmptyStatement = policyErr("Could not parse the policy: Statement is empty!") - policyErrMissingStatmentField = policyErr("Missing required field Statement") - policyErrInvalidVersion = policyErr("The policy must contain a valid version string") + policyErrResourceMismatch = policyErr("Action does not apply to any resource(s) in statement") + policyErrInvalidResource = policyErr("Policy has invalid resource") + policyErrInvalidPrincipal = policyErr("Invalid principal in policy") + policyErrInvalidAction = policyErr("Policy has invalid action") + policyErrInvalidPolicy = policyErr("This policy contains invalid Json") + policyErrInvalidFirstChar = policyErr("Policies must be valid JSON and the first byte must be '{'") + policyErrEmptyStatement = policyErr("Could not parse the policy: Statement is empty!") + policyErrMissingStatmentField = policyErr("Missing required field Statement") + policyErrInvalidVersion = policyErr("The policy must contain a valid version string") + policyErrInvalidConditionKey = policyErr("Policy has an invalid condition key") + policyErrConditionActionMismatch = policyErr("Conditions do not apply to combination of actions and resources in statement") + policyErrInvalidIPCondition = policyErr("Invalid IP address in Conditions") ) type BucketPolicy struct { @@ -103,32 +110,58 @@ func (bp *BucketPolicy) Validate(bucket string, iam IAMService) error { return nil } -func (bp *BucketPolicy) isAllowed(principal string, action Action, resource string, normalizeObjectKey objectKeyNormalizer) bool { +// decisionFor evaluates a single action against bp for principal/resource, +// returning the tri-state policyDecision. A statement whose principal/action/resource +// otherwise matches but whose Condition block can't be evaluated +// denies the whole decision immediately, regardless of that statement's own +// Effect — the same "can't rule out a hidden Deny" fail-closed contract +// iamapi/policy.EvaluateIdentityPolicies uses for identity policies, +// enforced per-statement here instead of per-document. In practice this +// branch is unreachable for any policy PutBucketPolicy accepted after +// Condition write-time validation existed — it only guards a document +// stored before that validation existed, or naming a future operator the +// gateway doesn't yet recognize. +func (bp *BucketPolicy) decisionFor(principal string, action Action, resource string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision { var isAllowed bool for _, statement := range bp.Statement { - if statement.findMatch(principal, action, resource, normalizeObjectKey) { - switch statement.Effect { - case BucketPolicyAccessTypeAllow: - isAllowed = true - case BucketPolicyAccessTypeDeny: - return false - } + matched, evaluable := statement.findMatch(principal, action, resource, condCtx, bp.Version, normalizeObjectKey) + if !evaluable { + return policyDecisionDeny + } + if !matched { + continue + } + switch statement.Effect { + case BucketPolicyAccessTypeAllow: + isAllowed = true + case BucketPolicyAccessTypeDeny: + return policyDecisionDeny } } - return isAllowed + if isAllowed { + return policyDecisionAllow + } + return policyDecisionNoMatch } -func (bp *BucketPolicy) publicDecisionFor(resource string, action Action, normalizeObjectKey objectKeyNormalizer) policyDecision { +// publicDecisionFor mirrors decisionFor for the anonymous/public-bucket-access +// path +func (bp *BucketPolicy) publicDecisionFor(resource string, action Action, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision { var isAllowed bool for _, statement := range bp.Statement { - if statement.isPublicFor(resource, action, normalizeObjectKey) { - switch statement.Effect { - case BucketPolicyAccessTypeAllow: - isAllowed = true - case BucketPolicyAccessTypeDeny: - return policyDecisionDeny - } + matched, evaluable := statement.isPublicFor(resource, action, condCtx, bp.Version, normalizeObjectKey) + if !evaluable { + return policyDecisionDeny + } + if !matched { + continue + } + switch statement.Effect { + case BucketPolicyAccessTypeAllow: + isAllowed = true + case BucketPolicyAccessTypeDeny: + return policyDecisionDeny } } @@ -156,6 +189,7 @@ type BucketPolicyItem struct { Principals Principals `json:"Principal"` Actions Actions `json:"Action"` Resources Resources `json:"Resource"` + Condition json.RawMessage `json:"Condition,omitempty"` } func (bpi *BucketPolicyItem) Validate(bucket string, iam IAMService) error { @@ -169,6 +203,16 @@ func (bpi *BucketPolicyItem) Validate(bucket string, iam IAMService) error { return err } + // Condition applicability is checked before the action/resource-type + // pairing below: AWS reports a Condition key that doesn't apply to the + // statement's actions even when those actions also don't apply to the + // statement's resource type, e.g. s3:prefix with s3:ListBucketMultipartUploads + // against an object resource — reported as the Condition mismatch, not + // the resource-type one. + if err := validateBucketPolicyCondition(bpi.Condition, bpi.Actions); err != nil { + return err + } + containsObjectAction := bpi.Resources.ContainsObjectPattern() containsBucketAction := bpi.Resources.ContainsBucketPattern() @@ -188,18 +232,27 @@ func (bpi *BucketPolicyItem) Validate(bucket string, iam IAMService) error { return nil } -func (bpi *BucketPolicyItem) findMatch(principal string, action Action, resource string, normalizeObjectKey objectKeyNormalizer) bool { - if bpi.Principals.Contains(principal) && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey) { - return true +// findMatch reports whether the statement's principal/action/resource cover +// this request, and — only when they do — whether its Condition block holds +// against condCtx. matched is only meaningful when evaluable is true; see +// condition.Evaluate and decisionFor's fail-closed handling of evaluable =false. +func (bpi *BucketPolicyItem) findMatch(principal string, action Action, resource string, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) { + if !(bpi.Principals.Contains(principal) && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) { + return false, true } - - return false + return condition.Evaluate(bpi.Condition, condCtx, string(version)) } // isPublicFor checks if the bucket policy statement grants public access -// for given resource and action -func (bpi *BucketPolicyItem) isPublicFor(resource string, action Action, normalizeObjectKey objectKeyNormalizer) bool { - return bpi.Principals.isPublic() && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey) +// for given resource and action, and — only when it otherwise matches — +// whether its Condition block holds against condCtx. A public statement's +// Condition is evaluated with whatever request-derived keys condCtx carries; +// there is no caller identity to resolve for an anonymous request +func (bpi *BucketPolicyItem) isPublicFor(resource string, action Action, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) { + if !(bpi.Principals.isPublic() && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) { + return false, true + } + return condition.Evaluate(bpi.Condition, condCtx, string(version)) } // isPublic checks if the statement grants public access @@ -250,29 +303,44 @@ func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) err return nil } -func VerifyBucketPolicy(policy []byte, access, bucket, object string, normalizeObjectKey objectKeyNormalizer, actions ...Action) error { +// verifyBucketPolicy parses policyBytes and evaluates it against every +// action, aggregating with the same precedence isAllowed uses for a single +// action: a Deny on any action wins immediately (returned along with that +// action, for building an AWS-shaped message); otherwise the decision is +// Allow only if every action has a matching Allow; otherwise NoMatch, +// paired with the first action that lacked one. Zero actions is +// conservatively NoMatch, not vacuously Allow. +func verifyBucketPolicy(policyBytes []byte, access, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, actions ...Action) (policyDecision, Action, error) { if len(actions) == 0 { - return s3err.GetAPIError(s3err.ErrAccessDenied) + return policyDecisionNoMatch, "", nil } - var bucketPolicy BucketPolicy - if err := json.Unmarshal(policy, &bucketPolicy); err != nil { - return fmt.Errorf("failed to parse the bucket policy: %w", err) + var bp BucketPolicy + if err := json.Unmarshal(policyBytes, &bp); err != nil { + return policyDecisionNoMatch, "", fmt.Errorf("failed to parse the bucket policy: %w", err) } resource := makePolicyResource(bucket, object, normalizeObjectKey) + result := policyDecisionAllow + var blamed Action for _, action := range actions { - if !bucketPolicy.isAllowed(access, action, resource, normalizeObjectKey) { - return s3err.GetAPIError(s3err.ErrAccessDenied) + switch d := bp.decisionFor(access, action, resource, condCtx, normalizeObjectKey); d { + case policyDecisionDeny: + return policyDecisionDeny, action, nil + case policyDecisionNoMatch: + if result != policyDecisionNoMatch { + result = policyDecisionNoMatch + blamed = action + } } } - return nil + return result, blamed, nil } // Checks if the bucket policy grants public access -func VerifyPublicBucketPolicy(policy []byte, bucket, object string, normalizeObjectKey objectKeyNormalizer, action Action) error { +func VerifyPublicBucketPolicy(policy []byte, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, action Action) error { var bucketPolicy BucketPolicy if err := json.Unmarshal(policy, &bucketPolicy); err != nil { return err @@ -280,7 +348,7 @@ func VerifyPublicBucketPolicy(policy []byte, bucket, object string, normalizeObj resource := makePolicyResource(bucket, object, normalizeObjectKey) - switch bucketPolicy.publicDecisionFor(resource, action, normalizeObjectKey) { + switch bucketPolicy.publicDecisionFor(resource, action, condCtx, normalizeObjectKey) { case policyDecisionAllow: return nil case policyDecisionDeny: diff --git a/auth/bucket_policy_condition.go b/auth/bucket_policy_condition.go new file mode 100644 index 00000000..70a2d00f --- /dev/null +++ b/auth/bucket_policy_condition.go @@ -0,0 +1,190 @@ +// 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 auth + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/versity/versitygw/internal/condition" +) + +// conditionKeyRule is one condition key's write-time compatibility check: a +// PutBucketPolicy statement naming this key in its Condition block is only +// accepted when appliesTo holds for every (non-wildcard) action the +// statement names +type conditionKeyRule struct { + appliesTo func(Action) bool + // ipSemantic marks a key AWS validates as an IP address/CIDR at write + // time, independent of which operator wraps it. + ipSemantic bool +} + +func anyAction(Action) bool { return true } + +// isListAction is s3:prefix/s3:delimiter/s3:max-keys' applicable-action set: +// s3:ListBucket and s3:ListBucketVersions, not s3:GetObject and — notably — +// not s3:ListBucketMultipartUploads either, so this is deliberately not +// "every List-shaped action". +func isListAction(a Action) bool { + return a == ListBucketAction || a == ListBucketVersionsAction +} + +// isAclPutAction is s3:x-amz-acl's applicable-action set: s3:PutObject, +// s3:PutBucketAcl, and s3:PutObjectAcl. s3:CreateBucket is excluded — AWS +// rejects s3:CreateBucket in any bucket-policy statement at all, a +// pre-existing, Condition-unrelated validation gap, since bucket policies +// attach to a bucket that must already exist. +func isAclPutAction(a Action) bool { + switch a { + case PutObjectAction, PutBucketAclAction, PutObjectAclAction: + return true + default: + return false + } +} + +// isVersionedAction is s3:VersionId's applicable-action set: the *Version* +// action family. +func isVersionedAction(a Action) bool { + switch a { + case GetObjectVersionAction, DeleteObjectVersionAction, GetObjectVersionAttributesAction, + GetObjectVersionTaggingAction, PutObjectVersionTaggingAction, DeleteObjectVersionTaggingAction: + return true + default: + return false + } +} + +// bucketPolicyConditionKeys is the fixed catalogue of condition keys this +// gateway's S3 bucket-policy Condition support recognizes, each mapped to +// the actions it may be used with. Keys are looked up case-insensitively +// (AWS documents condition key *names*, unlike their values, as +// case-insensitive: "AWS:SourceIp" is accepted the same as "aws:SourceIp"), +// so every key here is stored lowercase. +// +// This deliberately does not cover AWS's full S3 condition-key catalogue — +// tag-based keys (s3:ExistingObjectTag/*, s3:RequestObjectTag/*, +// s3:RequestObjectTagKeys), object-lock keys, s3:x-amz-server-side-encryption +// (the gateway never reads that header, so enforcing it would be +// misleading), and aws:MultiFactorAuthAge (no MFA concept here) are out of +// scope. A Condition naming one of those is still accepted at write time — +// the key just never appears in the runtime context, so any Condition +// depending on it simply never matches, the same as any other key this +// package doesn't populate. +var bucketPolicyConditionKeys = map[string]conditionKeyRule{ + // Generic keys: AWS accepts these with any action. + "aws:sourceip": {appliesTo: anyAction, ipSemantic: true}, + "aws:currenttime": {appliesTo: anyAction}, + "aws:epochtime": {appliesTo: anyAction}, + "aws:securetransport": {appliesTo: anyAction}, + "aws:useragent": {appliesTo: anyAction}, + "aws:referer": {appliesTo: anyAction}, + "aws:principalarn": {appliesTo: anyAction}, + "aws:username": {appliesTo: anyAction}, + "aws:userid": {appliesTo: anyAction}, + "aws:multifactorauthage": {appliesTo: anyAction}, + + // S3-specific keys: only valid with a specific action subset. + "s3:prefix": {appliesTo: isListAction}, + "s3:delimiter": {appliesTo: isListAction}, + "s3:max-keys": {appliesTo: isListAction}, + "s3:x-amz-acl": {appliesTo: isAclPutAction}, + "s3:versionid": {appliesTo: isVersionedAction}, +} + +// lookupConditionKeyRule finds key's rule case-insensitively. +func lookupConditionKeyRule(key string) (conditionKeyRule, bool) { + rule, ok := bucketPolicyConditionKeys[strings.ToLower(key)] + return rule, ok +} + +// validateBucketPolicyCondition checks a bucket-policy statement's raw +// Condition block against the same write-time rules real AWS enforces for +// PutBucketPolicy: +// +// - an unrecognized operator name -> "Invalid Condition type : " +// - a key outside bucketPolicyConditionKeys -> policyErrInvalidConditionKey +// - a key whose rule doesn't apply to some (non-wildcard) action in +// actions -> policyErrConditionActionMismatch. For an explicit +// multi-action list, EVERY action must support the key (e.g. +// ["s3:GetObject","s3:PutObject"] with the PutObject-only s3:x-amz-acl +// is rejected even though PutObject alone would accept it); a wildcard +// action pattern (containing '*' or '?', e.g. "s3:*" or +// "s3:PutObject*") is exempt from this check entirely, so both accept +// s3:x-amz-acl even though s3:* covers many actions that don't support +// it. +// - an ipSemantic key (aws:SourceIp) with a value that doesn't parse as +// an IP address or CIDR range -> policyErrInvalidIPCondition, +// regardless of which operator wraps it. +func validateBucketPolicyCondition(raw json.RawMessage, actions Actions) error { + block, err := condition.Parse(raw) + if err != nil { + op, ok := unrecognizedConditionOperator(raw) + if ok { + //lint:ignore ST1005 Reason: This error message is intended for end-user clarity and follows their expectations + return fmt.Errorf("Invalid Condition type : %s", op) + } + return policyErrInvalidPolicy + } + + concreteActions := make([]Action, 0, len(actions)) + for action := range actions { + if strings.ContainsAny(string(action), "*?") { + continue + } + concreteActions = append(concreteActions, action) + } + + for _, kvs := range block { + for key, values := range kvs { + rule, ok := lookupConditionKeyRule(key) + if !ok { + return policyErrInvalidConditionKey + } + for _, action := range concreteActions { + if !rule.appliesTo(action) { + return policyErrConditionActionMismatch + } + } + if rule.ipSemantic { + for _, v := range values { + if !condition.ParseIPOrCIDR(v) { + return policyErrInvalidIPCondition + } + } + } + } + } + return nil +} + +// unrecognizedConditionOperator re-walks raw's top-level operator names to +// find the first one ParseOperatorName rejects, for building AWS's exact +// "Invalid Condition type : " message — condition.Parse itself only +// reports that parsing failed, not which operator caused it. +func unrecognizedConditionOperator(raw json.RawMessage) (string, bool) { + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return "", false + } + for operator := range top { + if _, ok := condition.ParseOperatorName(operator); !ok { + return operator, true + } + } + return "", false +} diff --git a/auth/bucket_policy_condition_test.go b/auth/bucket_policy_condition_test.go new file mode 100644 index 00000000..58b716c6 --- /dev/null +++ b/auth/bucket_policy_condition_test.go @@ -0,0 +1,190 @@ +// 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 auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func actionSet(actions ...Action) Actions { + a := make(Actions, len(actions)) + for _, act := range actions { + a[act] = struct{}{} + } + return a +} + +func TestValidateBucketPolicyCondition(t *testing.T) { + tests := []struct { + name string + raw string + actions Actions + // wantErr is compared by message text, not type: unrecognized-operator + // errors carry a dynamic operator name via fmt.Errorf rather than a + // policyErr constant. + wantErr error + }{ + { + name: "no condition is valid", + raw: ``, + actions: actionSet(GetObjectAction), + }, + { + name: "recognized generic key with any action", + raw: `{"StringEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/foo"}}`, + actions: actionSet(GetObjectAction), + }, + { + // AWS: "AWS:SourceIp" (uppercase prefix) is accepted the same + // as "aws:SourceIp" - key names are case-insensitive. + name: "condition key recognized case-insensitively", + raw: `{"IpAddress":{"AWS:SourceIp":"10.0.0.0/8"}}`, + actions: actionSet(GetObjectAction), + }, + { + name: "unrecognized operator", + raw: `{"NotARealOperator":{"aws:SourceIp":"1.2.3.4/32"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErr("Invalid Condition type : NotARealOperator"), + }, + { + // AWS is case-sensitive about operator names specifically, + // unlike condition keys. + name: "operator name is case-sensitive", + raw: `{"stringequals":{"s3:prefix":"foo"}}`, + actions: actionSet(ListBucketAction), + wantErr: policyErr("Invalid Condition type : stringequals"), + }, + { + name: "unrecognized condition key", + raw: `{"StringEquals":{"s3:FakeKeyDoesNotExist":"foo"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErrInvalidConditionKey, + }, + { + name: "s3-specific key rejected for an unsupported action", + raw: `{"StringEquals":{"s3:prefix":"foo"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErrConditionActionMismatch, + }, + { + name: "s3-specific key accepted for its supported action", + raw: `{"StringEquals":{"s3:prefix":"foo"}}`, + actions: actionSet(ListBucketAction), + }, + { + // s3:prefix/delimiter/max-keys apply to + // ListBucket/ListBucketVersions only, NOT + // ListBucketMultipartUploads. + name: "s3:prefix rejected for ListBucketMultipartUploads", + raw: `{"StringEquals":{"s3:prefix":"foo"}}`, + actions: actionSet(ListBucketMultipartUploadsAction), + wantErr: policyErrConditionActionMismatch, + }, + { + name: "s3:x-amz-acl accepted for PutObject", + raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`, + actions: actionSet(PutObjectAction), + }, + { + name: "s3:x-amz-acl accepted for PutBucketAcl", + raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`, + actions: actionSet(PutBucketAclAction), + }, + { + name: "s3:x-amz-acl accepted for PutObjectAcl", + raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`, + actions: actionSet(PutObjectAclAction), + }, + { + name: "s3:VersionId accepted for GetObjectVersion", + raw: `{"StringEquals":{"s3:VersionId":"abc123"}}`, + actions: actionSet(GetObjectVersionAction), + }, + { + name: "s3:VersionId rejected for plain GetObject", + raw: `{"StringEquals":{"s3:VersionId":"abc123"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErrConditionActionMismatch, + }, + { + // Every action in an explicit multi-action list must support + // the key, even though PutObject alone would. + name: "multi-action statement requires every action to support the key", + raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`, + actions: actionSet(GetObjectAction, PutObjectAction), + wantErr: policyErrConditionActionMismatch, + }, + { + // A wildcard action ("s3:*", "s3:PutObject*", …) is exempt from + // the per-action applicability check entirely, even though it + // covers actions the key doesn't support. + name: "wildcard action is exempt from the action-applicability check", + raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`, + actions: actionSet(AllActions), + }, + { + name: "aws:SourceIp with a valid CIDR", + raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + actions: actionSet(GetObjectAction), + }, + { + name: "aws:SourceIp with a bare valid address", + raw: `{"IpAddress":{"aws:SourceIp":"203.0.113.5"}}`, + actions: actionSet(GetObjectAction), + }, + { + name: "aws:SourceIp with an invalid value", + raw: `{"IpAddress":{"aws:SourceIp":"not-an-ip"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErrInvalidIPCondition, + }, + { + // The IP-format check is keyed by condition-key identity, not + // by operator - it fires even under an operator that has + // nothing to do with IP semantics. + name: "aws:SourceIp invalid value rejected regardless of operator", + raw: `{"Null":{"aws:SourceIp":"true"}}`, + actions: actionSet(GetObjectAction), + wantErr: policyErrInvalidIPCondition, + }, + { + // A non-IP key under IpAddress is not itself validated as an + // IP - only recognized IP-semantic keys are. + name: "non-IP key under IpAddress operator is not IP-validated", + raw: `{"IpAddress":{"aws:Referer":"not-an-ip"}}`, + actions: actionSet(GetObjectAction), + }, + { + name: "malformed condition JSON", + raw: `not json`, + actions: actionSet(GetObjectAction), + wantErr: policyErrInvalidPolicy, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBucketPolicyCondition([]byte(tt.raw), tt.actions) + if tt.wantErr == nil { + assert.NoError(t, err) + return + } + assert.EqualError(t, err, tt.wantErr.Error()) + }) + } +} diff --git a/auth/bucket_policy_principals.go b/auth/bucket_policy_principals.go index 3f17d85e..a4ba83f9 100644 --- a/auth/bucket_policy_principals.go +++ b/auth/bucket_policy_principals.go @@ -100,7 +100,7 @@ func (p Principals) Validate(iam IAMService) error { return policyErrInvalidPrincipal } - accs, err := CheckIfAccountsExist(p.ToSlice(), iam) + accs, err := iam.ResolveAccounts(p.ToSlice()) if err != nil { return err } diff --git a/auth/bucket_policy_test.go b/auth/bucket_policy_test.go new file mode 100644 index 00000000..28f9cd8e --- /dev/null +++ b/auth/bucket_policy_test.go @@ -0,0 +1,158 @@ +// 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 auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBucketPolicyDecision_Condition(t *testing.T) { + tests := []struct { + name string + policy string + action Action + object string + condCtx map[string][]string + want policyDecision + }{ + { + name: "Allow with matching Condition grants access", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}, + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`, + action: GetObjectAction, + object: "key", + condCtx: map[string][]string{"aws:UserAgent": {"good-agent"}}, + want: policyDecisionAllow, + }, + { + name: "Allow with non-matching Condition does not grant access", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}, + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`, + action: GetObjectAction, + object: "key", + condCtx: map[string][]string{"aws:UserAgent": {"bad-agent"}}, + want: policyDecisionNoMatch, + }, + { + name: "Allow with no matching context key does not grant access", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}, + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`, + action: GetObjectAction, + object: "key", + condCtx: nil, + want: policyDecisionNoMatch, + }, + { + name: "Deny with matching Condition wins over an unconditional Allow", + policy: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*"}, + {"Effect":"Deny","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`, + action: GetObjectAction, + object: "key", + condCtx: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}, + want: policyDecisionDeny, + }, + { + name: "Deny with non-matching Condition leaves the unconditional Allow standing", + policy: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*"}, + {"Effect":"Deny","Principal":{"AWS":"*"},"Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`, + action: GetObjectAction, + object: "key", + condCtx: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: policyDecisionAllow, + }, + { + // s3:prefix, wired up from the request's "prefix" query param by + // the S3 auth middleware, is exercised end to end against a + // ListBucket-shaped policy. + name: "s3:prefix condition key matches against ListBucket", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}, + "Action":"s3:ListBucket","Resource":"arn:aws:s3:::mybucket", + "Condition":{"StringEquals":{"s3:prefix":"photos/"}}}]}`, + action: ListBucketAction, + object: "", + condCtx: map[string][]string{"s3:prefix": {"photos/"}}, + want: policyDecisionAllow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision, _, err := verifyBucketPolicy([]byte(tt.policy), "someaccess", "mybucket", tt.object, tt.condCtx, nil, tt.action) + assert.NoError(t, err) + assert.Equal(t, tt.want, decision) + }) + } +} + +func TestBucketPolicyDecision_UnevaluableConditionFailsClosed(t *testing.T) { + // This shape (an unrecognized operator) can no longer be written via + // PutBucketPolicy once write-time validation rejects it - this test + // exercises the defense-in-depth fallback for a document that reached + // storage some other way (a legacy write, a migration, ...), the same + // scenario iamapi/policy.EvaluateIdentityPolicies guards against. + policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}, + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"SomeFutureOperator":{"aws:UserAgent":"good-agent"}}}]}` + + decision, _, err := verifyBucketPolicy([]byte(policy), "someaccess", "mybucket", "key", nil, nil, GetObjectAction) + assert.NoError(t, err) + assert.Equal(t, policyDecisionDeny, decision) +} + +func TestVerifyPublicBucketPolicy_Condition(t *testing.T) { + tests := []struct { + name string + policy string + condCtx map[string][]string + wantErr error + }{ + { + name: "public Allow with matching Condition grants access", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*", + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`, + condCtx: map[string][]string{"aws:UserAgent": {"good-agent"}}, + wantErr: nil, + }, + { + name: "public Allow with non-matching Condition denies access", + policy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*", + "Action":"s3:GetObject","Resource":"arn:aws:s3:::mybucket/*", + "Condition":{"StringEquals":{"aws:UserAgent":"good-agent"}}}]}`, + condCtx: map[string][]string{"aws:UserAgent": {"bad-agent"}}, + wantErr: errAccessDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := VerifyPublicBucketPolicy([]byte(tt.policy), "mybucket", "key", tt.condCtx, nil, GetObjectAction) + if tt.wantErr == nil { + assert.NoError(t, err) + return + } + assert.Equal(t, tt.wantErr, err) + }) + } +} diff --git a/auth/condition_context.go b/auth/condition_context.go new file mode 100644 index 00000000..7d1e5cc5 --- /dev/null +++ b/auth/condition_context.go @@ -0,0 +1,68 @@ +// 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 auth + +import ( + "strconv" + "time" + + "github.com/gofiber/fiber/v3" +) + +// requestConditionContext builds the IAM policy-condition keys describing +// this request — aws:SourceIp, aws:SecureTransport, aws:CurrentTime and +// friends — for identity-policy and bucket-policy Condition blocks to +// evaluate against. The identity-derived keys (aws:PrincipalArn, +// aws:username, aws:PrincipalTag/*, …) are deliberately absent: the S3 +// gateway has no way to know them, so the IAM service fills them in itself +// when it evaluates an identity policy. +func requestConditionContext(ctx fiber.Ctx) map[string][]string { + now := time.Now().UTC() + condCtx := map[string][]string{ + "aws:CurrentTime": {now.Format(time.RFC3339)}, + "aws:EpochTime": {strconv.FormatInt(now.Unix(), 10)}, + "aws:SecureTransport": {strconv.FormatBool(ctx.Secure())}, + } + // ctx.IP() is the real peer address: the gateway's fiber app configures + // neither ProxyHeader nor TrustProxy, so no client-supplied header can + // influence it. Adding either for logging would make aws:SourceIp + // client-controlled — revisit this if that ever changes. + if ip := ctx.IP(); ip != "" { + condCtx["aws:SourceIp"] = []string{ip} + } + if ua := ctx.Get("User-Agent"); ua != "" { + condCtx["aws:UserAgent"] = []string{ua} + } + if ref := ctx.Get("Referer"); ref != "" { + condCtx["aws:Referer"] = []string{ref} + } + if prefix := ctx.Query("prefix"); prefix != "" { + condCtx["s3:prefix"] = []string{prefix} + } + if delim := ctx.Query("delimiter"); delim != "" { + condCtx["s3:delimiter"] = []string{delim} + } + if maxKeys := ctx.Query("max-keys"); maxKeys != "" { + condCtx["s3:max-keys"] = []string{maxKeys} + } + if acl := ctx.Get("X-Amz-Acl"); acl != "" { + condCtx["s3:x-amz-acl"] = []string{acl} + } + if versionID := ctx.Query("versionId"); versionID != "" { + condCtx["s3:VersionId"] = []string{versionID} + } + + return condCtx +} diff --git a/auth/iam.go b/auth/iam.go index 13f47517..9d642e54 100644 --- a/auth/iam.go +++ b/auth/iam.go @@ -22,6 +22,27 @@ import ( "github.com/versity/versitygw/s3err" ) +// resolveAccountsByLookup implements ResolveAccounts for backends that have +// no batch endpoint, by calling getUserAccount once per access key and +// collecting the ones that don't exist. +func resolveAccountsByLookup(accessKeyIDs []string, getUserAccount func(string) (Account, error)) ([]string, error) { + missing := []string{} + for _, access := range accessKeyIDs { + _, err := getUserAccount(access) + if err != nil { + if err == ErrNoSuchUser || err == s3err.GetAPIError(s3err.ErrAdminUserNotFound) { + missing = append(missing, access) + continue + } + if errors.Is(err, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)) { + return nil, err + } + return nil, fmt.Errorf("check user account: %w", err) + } + } + return missing, nil +} + type Role string const ( @@ -51,6 +72,27 @@ type Account struct { UserID int `json:"userID"` GroupID int `json:"groupID"` ProjectID int `json:"projectID"` + + // SessionToken and IsSession describe a temporary credential minted by + // AssumeRoleWithWebIdentity, and are set only by the S3 auth + // middlewares for the duration of one request. They ride on Account + // rather than on auth.AccessOptions so the ~55 controller sites that + // already forward the request's Account into an authorization check + // carry them without a single edit. + // + // Both are json:"-": a session is request state, never persisted by an + // IAM backend nor echoed by the admin API. + SessionToken string `json:"-"` + IsSession bool `json:"-"` +} + +// String elides the two credential-bearing fields so an Account can't leak +// them into a log line through a %v/%+v verb. debuglogger redacts the +// X-Amz-Security-Token *header*, which does nothing for a struct printed +// after the token has been parsed out of it. +func (a Account) String() string { + return fmt.Sprintf("Account{Access:%s, Secret:REDACTED, Role:%s, UserID:%d, GroupID:%d, ProjectID:%d, SessionToken:REDACTED, IsSession:%t}", + a.Access, a.Role, a.UserID, a.GroupID, a.ProjectID, a.IsSession) } type ListUserAccountsResult struct { @@ -98,6 +140,7 @@ func updateAcc(acc *Account, props MutableProps) { type IAMService interface { CreateAccount(account Account) error GetUserAccount(access string) (Account, error) + ResolveAccounts(accessKeyIDs []string) ([]string, error) UpdateUserAccount(access string, props MutableProps) error DeleteUserAccount(access string) error ListUserAccounts() ([]Account, error) @@ -109,6 +152,13 @@ var ( ErrUserExists = errors.New("user already exists") // ErrNoSuchUser is returned when the user does not exist ErrNoSuchUser = errors.New("user not found") + // ErrInvalidSessionToken is returned when a request's + // X-Amz-Security-Token is missing for a temporary (ASIA…) access key, + // doesn't match the session that key belongs to, or is present + // alongside a permanent credential. Callers render it as S3's + // InvalidToken, distinct from the InvalidAccessKeyId that ErrNoSuchUser + // produces — matching real S3, which reports the two separately. + ErrInvalidSessionToken = errors.New("invalid session token") ) type Opts struct { @@ -153,6 +203,15 @@ type Opts struct { IpaUser string IpaPassword string IpaInsecure bool + StandaloneIAMEndpoint string + StandaloneIAMAccess string + StandaloneIAMSecret string + StandaloneClientCert string + StandaloneClientCertKey string + StandaloneServerCA string + StandaloneDefaultUserID int + StandaloneDefaultGroupID int + StandaloneDefaultProjectID int } func New(o *Opts) (IAMService, error) { @@ -160,6 +219,31 @@ func New(o *Opts) (IAMService, error) { var err error switch { + case o.StandaloneIAMEndpoint != "": + svc, err = NewIAMServiceStandalone(o.RootAccount, IAMServiceStandaloneConfig{ + Endpoint: o.StandaloneIAMEndpoint, + Access: o.StandaloneIAMAccess, + Secret: o.StandaloneIAMSecret, + ClientCert: o.StandaloneClientCert, + ClientCertKey: o.StandaloneClientCertKey, + ServerCA: o.StandaloneServerCA, + DefaultUserID: o.StandaloneDefaultUserID, + DefaultGroupID: o.StandaloneDefaultGroupID, + DefaultProjectID: o.StandaloneDefaultProjectID, + }) + fmt.Printf("initializing standalone IAM with %q\n", o.StandaloneIAMEndpoint) + if err != nil { + return nil, err + } + // Never cache-wrapped, unlike every other backend below: IAMCache + // only implements the base IAMService methods, so wrapping this + // backend in it would silently strip the SigningKeyProvider/ + // PolicyEvaluator interfaces signature verification and policy + // enforcement depend on — not just skip a performance + // optimization, but break both outright. + // + // TODO: Do we need to implement cache for this ? + return svc, nil case o.Dir != "": svc, err = NewInternal(o.RootAccount, o.Dir) fmt.Printf("initializing internal IAM with %q\n", o.Dir) diff --git a/auth/iam_cache.go b/auth/iam_cache.go index 2eea1ba0..35e67022 100644 --- a/auth/iam_cache.go +++ b/auth/iam_cache.go @@ -170,6 +170,13 @@ func (c *IAMCache) GetUserAccount(access string) (Account, error) { return a, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. It +// loops over the cache's own GetUserAccount so lookups benefit from caching +// the same way a single-account check would. +func (c *IAMCache) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, c.GetUserAccount) +} + // DeleteUserAccount deletes account from IAM service and cache func (c *IAMCache) DeleteUserAccount(access string) error { err := c.service.DeleteUserAccount(access) diff --git a/auth/iam_internal.go b/auth/iam_internal.go index f9901de2..4972ee9d 100644 --- a/auth/iam_internal.go +++ b/auth/iam_internal.go @@ -117,6 +117,11 @@ func (s *IAMServiceInternal) GetUserAccount(access string) (Account, error) { return acct, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (s *IAMServiceInternal) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, s.GetUserAccount) +} + // UpdateUserAccount updates the specified user account fields. Returns // ErrNoSuchUser if the account does not exist. func (s *IAMServiceInternal) UpdateUserAccount(access string, props MutableProps) error { diff --git a/auth/iam_ipa.go b/auth/iam_ipa.go index e54e2e03..2cd5ea40 100644 --- a/auth/iam_ipa.go +++ b/auth/iam_ipa.go @@ -211,6 +211,11 @@ func (ipa *IpaIAMService) GetUserAccount(access string) (Account, error) { return account, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (ipa *IpaIAMService) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, ipa.GetUserAccount) +} + func (ipa *IpaIAMService) UpdateUserAccount(access string, props MutableProps) error { return fmt.Errorf("not implemented") } diff --git a/auth/iam_ldap.go b/auth/iam_ldap.go index c7c7879d..c20c33f5 100644 --- a/auth/iam_ldap.go +++ b/auth/iam_ldap.go @@ -239,6 +239,11 @@ func (ld *LdapIAMService) GetUserAccount(access string) (Account, error) { }, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (ld *LdapIAMService) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, ld.GetUserAccount) +} + func (ld *LdapIAMService) UpdateUserAccount(access string, props MutableProps) error { req := ldap.NewModifyRequest(ld.buildUserDN(access), nil) if props.Secret != nil { diff --git a/auth/iam_s3_object.go b/auth/iam_s3_object.go index f8dafa09..d8ba2199 100644 --- a/auth/iam_s3_object.go +++ b/auth/iam_s3_object.go @@ -149,6 +149,11 @@ func (s *IAMServiceS3) GetUserAccount(access string) (Account, error) { return acct, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (s *IAMServiceS3) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, s.GetUserAccount) +} + func (s *IAMServiceS3) UpdateUserAccount(access string, props MutableProps) error { s.Lock() defer s.Unlock() diff --git a/auth/iam_single.go b/auth/iam_single.go index 9cf3e249..26e2c317 100644 --- a/auth/iam_single.go +++ b/auth/iam_single.go @@ -45,6 +45,11 @@ func (s IAMServiceSingle) GetUserAccount(access string) (Account, error) { return Account{}, s3err.GetAPIError(s3err.ErrAdminUserNotFound) } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (s IAMServiceSingle) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, s.GetUserAccount) +} + // UpdateUserAccount no accounts in single tenant mode func (IAMServiceSingle) UpdateUserAccount(access string, props MutableProps) error { return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) diff --git a/auth/iam_standalone.go b/auth/iam_standalone.go new file mode 100644 index 00000000..76407917 --- /dev/null +++ b/auth/iam_standalone.go @@ -0,0 +1,505 @@ +// 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 auth + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/versity/versitygw/iamapi/private" + "github.com/versity/versitygw/internal/netutil" + "github.com/versity/versitygw/internal/sigv4auth" + "github.com/versity/versitygw/s3err" +) + +const ( + // standaloneSigningRegion/standaloneSigningService are the SigV4 + // envelope this client signs its own calls to the private endpoints with + standaloneSigningRegion = "us-east-1" + standaloneSigningService = sigv4auth.ServiceIAM + + standaloneRequestTimeout = 10 * time.Second +) + +// IAMServiceStandaloneConfig configures IAMServiceStandalone. +type IAMServiceStandaloneConfig struct { + // Endpoint is either a "host:port" TCP address (mTLS required - + // ClientCert/ClientCertKey/ServerCA) or a unix socket path, matching + // the standalone IAM service's own --private-ports address shape. + Endpoint string + // Access/Secret are this client's own SigV4 identity — the credential + // it signs its private requests with. Defaults both to the + // gateway's root account when unset. + Access string + Secret string + // ClientCert/ClientCertKey/ServerCA configure outbound mTLS. Required + // (all three) for a TCP Endpoint; unused for a unix socket Endpoint. + ClientCert string + ClientCertKey string + ServerCA string + // DefaultUserID/GroupID/ProjectID are assigned to every resolved + // (non-root) account. The standalone IAM service's user model + // (iamapi/types.User, mirroring real AWS IAM) has no POSIX uid/gid/ + // project-id concept, so there is no per-user value to fetch instead — + // every standalone-backed account shares one POSIX identity for + // backend file-ownership purposes. + DefaultUserID int + DefaultGroupID int + DefaultProjectID int +} + +// IAMServiceStandalone is the S3 gateway's client for a standalone IAM +// service's private endpoints. It never holds a plaintext secret for any account +// but its own signing identity and the locally-known root account — every +// other account's secret stays inside the IAM service process. +// CreateAccount/UpdateUserAccount/ DeleteUserAccount/ListUserAccounts +// are unsupported here for the same reason: mutating a user requires setting a secret, which must never +// flow into this process — manage users via the IAM service's own control-plane API instead. +type IAMServiceStandalone struct { + client *http.Client + baseURL string + access string + secret string + rootAcc Account + cfg IAMServiceStandaloneConfig +} + +var ( + _ IAMService = (*IAMServiceStandalone)(nil) + _ SigningKeyProvider = (*IAMServiceStandalone)(nil) + _ PolicyEvaluator = (*IAMServiceStandalone)(nil) +) + +// NewIAMServiceStandalone constructs the standalone IAM service client. +// rootAcc is the gateway's own root account — always resolved locally, +// never round-tripped through the IAM service. +func NewIAMServiceStandalone(rootAcc Account, cfg IAMServiceStandaloneConfig) (*IAMServiceStandalone, error) { + if cfg.Endpoint == "" { + return nil, fmt.Errorf("iam standalone: endpoint is required") + } + + access := cfg.Access + if access == "" { + access = rootAcc.Access + } + secret := cfg.Secret + if secret == "" { + secret = rootAcc.Secret + } + + client, baseURL, err := newStandaloneHTTPClient(cfg) + if err != nil { + return nil, err + } + + return &IAMServiceStandalone{ + client: client, + baseURL: baseURL, + access: access, + secret: secret, + rootAcc: rootAcc, + cfg: cfg, + }, nil +} + +func newStandaloneHTTPClient(cfg IAMServiceStandaloneConfig) (*http.Client, string, error) { + if netutil.IsUnixSocketPath(cfg.Endpoint) { + sock := cfg.Endpoint + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", sock) + }, + } + // The host in this URL is never actually resolved/dialed — the + // DialContext override above always connects to the unix socket + // regardless — it just needs to be a syntactically valid URL. + return &http.Client{Transport: transport, Timeout: standaloneRequestTimeout}, "http://unix", nil + } + + if cfg.ClientCert == "" || cfg.ClientCertKey == "" || cfg.ServerCA == "" { + return nil, "", fmt.Errorf("iam standalone: client-cert, client-cert-key, and server-ca are all required for a TCP endpoint (%q)", cfg.Endpoint) + } + + cert, err := netutil.LoadClientCert(cfg.ClientCert, cfg.ClientCertKey) + if err != nil { + return nil, "", fmt.Errorf("iam standalone: %w", err) + } + pool, err := netutil.LoadCACertPool(cfg.ServerCA) + if err != nil { + return nil, "", fmt.Errorf("iam standalone: %w", err) + } + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + }, + } + return &http.Client{Transport: transport, Timeout: standaloneRequestTimeout}, "https://" + cfg.Endpoint, nil +} + +// doPrivateRequest signs reqBody as this client's own identity (s.access/ +// s.secret, the one place in this file that touches a secret directly — +// signing an outbound request as itself, not verifying an inbound one) and +// POSTs it to path, unmarshaling the response into respBody. +// +// A 403 is dispatched on the error body's code: an unresolvable access key +// becomes ErrNoSuchUser (matching IAMService.GetUserAccount's contract), a +// rejected security token becomes ErrInvalidSessionToken, and anything +// else — most importantly this gateway's own IAM-client credential being +// rejected — stays a plain error, so a gateway misconfiguration surfaces as +// a server fault instead of telling the end user their access key doesn't +// exist. +func (s *IAMServiceStandalone) doPrivateRequest(path string, reqBody, respBody any) error { + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("iam standalone: marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, s.baseURL+path, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("iam standalone: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + payloadHash := sigv4auth.PayloadSHA256Hex(bodyBytes) + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + + signingTime := time.Now().UTC() + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(s.secret, yyyymmdd, standaloneSigningRegion, standaloneSigningService) + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = s.access + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, standaloneSigningRegion, standaloneSigningService) + in.PayloadHash = payloadHash + in.SigningTime = signingTime + in.DisableURIPathEscaping = true + result := sigv4auth.BuildAndSign(derivedKey, in) + req.Header.Set("X-Amz-Date", result.AmzDate) + req.Header.Set("Authorization", result.AuthorizationHeader) + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("iam standalone: request to %s failed: %w", path, err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("iam standalone: read response from %s: %w", path, err) + } + + if resp.StatusCode != http.StatusOK { + return standaloneResponseError(path, resp.StatusCode, respBytes) + } + + if respBody != nil { + if err := json.Unmarshal(respBytes, respBody); err != nil { + return fmt.Errorf("iam standalone: unmarshal response from %s: %w", path, err) + } + } + return nil +} + +// standaloneResponseError turns a non-200 private-endpoint response into +// the sentinel the S3 request pipeline dispatches on, using the JSON error +// body's machine-readable code rather than the status alone (403 covers +// several distinct failures, only two of which are about the *end user's* +// credential). +func standaloneResponseError(path string, status int, body []byte) error { + var errBody struct { + Error string `json:"error"` + Code string `json:"code"` + } + // A body that doesn't parse leaves Code empty, which falls through to + // the generic error below — the safe direction, since misreporting a + // server fault as "no such user" is what this dispatch exists to avoid. + _ = json.Unmarshal(body, &errBody) + + switch errBody.Code { + case private.CodeNoSuchIdentity: + return ErrNoSuchUser + case private.CodeInvalidToken: + return ErrInvalidSessionToken + } + + return fmt.Errorf("iam standalone: %s returned %d: %s", path, status, string(body)) +} + +// DeriveSigningKey implements SigningKeyProvider. Root is special-cased +// locally: its secret is already known to this process either way, so +// there's no reason to round-trip it through the IAM service. +func (s *IAMServiceStandalone) DeriveSigningKey(access, sessionToken, date, region, service string) ([]byte, Account, error) { + if access == s.rootAcc.Access { + if sessionToken != "" { + return nil, Account{}, ErrInvalidSessionToken + } + return sigv4auth.DeriveKey(s.rootAcc.Secret, date, region, service), s.rootAcc, nil + } + + var resp private.DeriveSigningKeyResponse + err := s.doPrivateRequest(private.DerivePath, private.DeriveSigningKeyRequest{ + AccessKeyID: access, + SessionToken: sessionToken, + Date: date, + Region: region, + Service: service, + }, &resp) + if err != nil { + return nil, Account{}, err + } + + return resp.DerivedKey, s.accountFor(access, sessionToken), nil +} + +// accountFor builds the Account metadata DeriveSigningKey/GetUserAccount +// return for a resolved non-root identity +func (s *IAMServiceStandalone) accountFor(access, sessionToken string) Account { + return Account{ + Access: access, + Role: RoleUser, + UserID: s.cfg.DefaultUserID, + GroupID: s.cfg.DefaultGroupID, + ProjectID: s.cfg.DefaultProjectID, + SessionToken: sessionToken, + IsSession: sigv4auth.IsTempAccessKeyID(access), + } +} + +// EvaluatePolicy implements PolicyEvaluator, evaluating every action in +// actions against resource in a single request rather than one round trip +// per action. +func (s *IAMServiceStandalone) EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) { + actionStrs := make([]string, len(actions)) + for i, action := range actions { + actionStrs[i] = string(action) + } + + var resp private.EvaluatePolicyResponse + err := s.doPrivateRequest(private.EvaluatePath, private.EvaluatePolicyRequest{ + AccessKeyID: access, + SessionToken: sessionToken, + Actions: actionStrs, + Resources: resources, + Condition: condition, + }, &resp) + if err != nil { + return PolicyEvaluation{}, err + } + if len(resp.Decisions) != len(resources) { + // A protocol mismatch between the gateway and IAM service builds — + // fail closed rather than silently under- or over-evaluating the + // requested matrix. + return PolicyEvaluation{}, fmt.Errorf("iam standalone: evaluate-policy returned %d resource decisions for %d resources", len(resp.Decisions), len(resources)) + } + + decisions, err := decisionMatrixFromWire(resp.Decisions, len(actions)) + if err != nil { + return PolicyEvaluation{}, err + } + + eval := PolicyEvaluation{ + Decisions: decisions, + PrincipalArn: resp.PrincipalArn, + } + + if resp.HasSessionPolicy { + if len(resp.SessionDecisions) != len(resources) { + return PolicyEvaluation{}, fmt.Errorf("iam standalone: evaluate-policy returned %d session-decision rows for %d resources", len(resp.SessionDecisions), len(resources)) + } + sessionDecisions, err := decisionMatrixFromWire(resp.SessionDecisions, len(actions)) + if err != nil { + return PolicyEvaluation{}, err + } + eval.HasSessionPolicy = true + eval.SessionDecisions = sessionDecisions + } + + return eval, nil +} + +// decisionMatrixFromWire converts one wire decision matrix, checking every +// row is the expected width. A short row is a protocol mismatch between +// gateway and IAM service builds, and is failed closed rather than padded. +func decisionMatrixFromWire(rows [][]string, actionCount int) ([][]policyDecision, error) { + out := make([][]policyDecision, len(rows)) + for i, perAction := range rows { + if len(perAction) != actionCount { + return nil, fmt.Errorf("iam standalone: evaluate-policy returned %d action decisions for %d actions", len(perAction), actionCount) + } + out[i] = make([]policyDecision, len(perAction)) + for j, d := range perAction { + out[i][j] = decisionFromWireValue(d) + } + } + return out, nil +} + +// decisionFromWireValue translates the private endpoint's wire-format +// Decision string to the auth package's own policyDecision. An unrecognized +// value (a protocol mismatch between mismatched gateway/IAM-service builds) +// fails closed as Deny rather than silently granting access. +func decisionFromWireValue(v string) policyDecision { + switch v { + case private.DecisionAllow: + return policyDecisionAllow + case private.DecisionNoMatch: + return policyDecisionNoMatch + default: + return policyDecisionDeny + } +} + +// GetUserAccount resolves access via the resolve-identity endpoint, which +// answers existence and principal identity while returning no credential +// material at all. This is not blanket-unsupported like the mutating +// methods below: ResolveAccounts (bucket-policy Principal and ACL grantee +// validation) depends on GetUserAccount working to tell a nonexistent +// grantee (ErrNoSuchUser) apart from an unsupported one +// (ErrAdminMethodNotSupported, which it treats as fatal). +// +// Callers that need to validate several access keys at once should use +// ResolveAccounts instead — one round trip for the whole set rather than +// one per key. +func (s *IAMServiceStandalone) GetUserAccount(access string) (Account, error) { + if access == s.rootAcc.Access { + return s.rootAcc, nil + } + + accounts, err := s.resolveAccountDetails([]string{access}) + if err != nil { + return Account{}, err + } + if !accounts[0].Found { + return Account{}, ErrNoSuchUser + } + return accounts[0].Account, nil +} + +// resolvedAccount is one resolveAccountDetails result. The zero value means +// "no such access key". +type resolvedAccount struct { + Found bool + // IsSession distinguishes an ephemeral AssumeRoleWithWebIdentity + // session from a long-term user. Callers persisting a reference to a + // principal (a bucket policy Principal, an ACL grantee, a bucket owner) + // must refuse a session: the ASIA… key it is named by stops existing + // when the session expires, leaving a reference that can never match + // and, for a bucket owner, a bucket nobody but root can administer. + IsSession bool + Account Account +} + +// resolveAccountDetails resolves every access key in accesses in a single +// round trip, returning one positional result per input. Only the root +// account is answered locally; a root access key mixed into the batch still +// costs nothing, since it never reaches the IAM service. +func (s *IAMServiceStandalone) resolveAccountDetails(accesses []string) ([]resolvedAccount, error) { + out := make([]resolvedAccount, len(accesses)) + + // Root is known to this process, so it is answered here and left out of + // the request entirely — the IAM service has no record of it. + remote := make([]string, 0, len(accesses)) + remoteIdx := make([]int, 0, len(accesses)) + for i, access := range accesses { + if access == s.rootAcc.Access { + out[i] = resolvedAccount{Found: true, Account: s.rootAcc} + continue + } + remote = append(remote, access) + remoteIdx = append(remoteIdx, i) + } + if len(remote) == 0 { + return out, nil + } + + var resp private.ResolveIdentityResponse + err := s.doPrivateRequest(private.ResolveIdentityPath, private.ResolveIdentityRequest{ + AccessKeyIDs: remote, + }, &resp) + if err != nil { + return nil, err + } + if len(resp.Identities) != len(remote) { + // A protocol mismatch between the gateway and IAM service builds — + // fail closed rather than silently mis-attributing results to the + // wrong access keys. + return nil, fmt.Errorf("iam standalone: resolve-identity returned %d identities for %d access keys", len(resp.Identities), len(remote)) + } + + for i, identity := range resp.Identities { + if !identity.Found { + continue + } + out[remoteIdx[i]] = resolvedAccount{ + Found: true, + IsSession: identity.Kind == private.KindSession, + // No session token is known here, and none is needed: this + // Account answers "who is this" for validation, never + // authenticates a request. + Account: s.accountFor(remote[i], ""), + } + } + return out, nil +} + +// ResolveAccounts returns the subset of accessKeyIDs that do not exist, in +// a single round trip. A temporary (ASIA…) session access key counts as +// nonexistent even while its session is live — see resolvedAccount.IsSession. +func (s *IAMServiceStandalone) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + resolved, err := s.resolveAccountDetails(accessKeyIDs) + if err != nil { + return nil, fmt.Errorf("check user account: %w", err) + } + missing := []string{} + for i, acc := range resolved { + if !acc.Found || acc.IsSession { + missing = append(missing, accessKeyIDs[i]) + } + } + return missing, nil +} + +// CreateAccount is not supported +func (s *IAMServiceStandalone) CreateAccount(Account) error { + return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) +} + +// UpdateUserAccount is not supported +func (s *IAMServiceStandalone) UpdateUserAccount(string, MutableProps) error { + return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) +} + +// DeleteUserAccount is not supported +func (s *IAMServiceStandalone) DeleteUserAccount(string) error { + return s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) +} + +// ListUserAccounts is not supported +func (s *IAMServiceStandalone) ListUserAccounts() ([]Account, error) { + return nil, s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) +} + +func (s *IAMServiceStandalone) Shutdown() error { + s.client.CloseIdleConnections() + return nil +} diff --git a/auth/iam_standalone_test.go b/auth/iam_standalone_test.go new file mode 100644 index 00000000..8f055888 --- /dev/null +++ b/auth/iam_standalone_test.go @@ -0,0 +1,356 @@ +// 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 auth + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/versity/versitygw/iamapi" + "github.com/versity/versitygw/iamapi/private" + "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/netutil" + "github.com/versity/versitygw/internal/sigv4auth" + "github.com/versity/versitygw/s3err" +) + +const standaloneTestRootAccess = "AKIDROOT" +const standaloneTestRootSecret = "ROOTSECRET" + +// standaloneTestServer starts a real private.PrivateAPI on a unix socket in +// t.TempDir(), backed by a real file storage.Storer — an actual server, not +// a hand-rolled mock — so IAMServiceStandalone is exercised against exactly +// the same code path the smoke-tested `versitygw iam` binary runs. +func standaloneTestServer(t *testing.T) (store storage.Storer, sockPath string) { + t.Helper() + + store, err := storage.New(storage.Config{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + + p, err := private.New(store, iamapi.RootCredentials{ + Access: standaloneTestRootAccess, + Secret: standaloneTestRootSecret, + }) + if err != nil { + t.Fatalf("private.New: %v", err) + } + + // A unix socket path is limited to ~104 bytes on macOS (sockaddr_un), + // which t.TempDir() alone can exceed once it embeds this test's full + // name — os.MkdirTemp with a short, fixed prefix keeps it well under + // that regardless of the test name. + sockDir, err := os.MkdirTemp("", "vgw-priv") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + sockPath = filepath.Join(sockDir, "p.sock") + + errCh := make(chan error, 1) + go func() { + errCh <- p.ServeMultiPort([]string{sockPath}, netutil.TLSOptions{}) + }() + + waitForSocket(t, sockPath, errCh) + + t.Cleanup(func() { + if err := p.Shutdown(); err != nil { + t.Logf("shutdown private API: %v", err) + } + }) + + return store, sockPath +} + +func waitForSocket(t *testing.T, path string, errCh <-chan error) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + select { + case err := <-errCh: + t.Fatalf("ServeMultiPort exited early: %v", err) + default: + } + conn, err := net.Dial("unix", path) + if err == nil { + conn.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("private API socket %s never became ready", path) +} + +func createStandaloneTestUser(t *testing.T, store storage.Storer, userName, accessKeyID, secret, policyDocument string) { + t.Helper() + ctx := context.Background() + + if _, err := store.CreateUser(ctx, types.User{UserName: userName, Path: "/", CreateDate: time.Now().UTC()}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateAccessKey(ctx, storage.CreateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + SecretAccessKey: secret, + Status: "Active", + CreateDate: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + if policyDocument != "" { + if err := store.PutUserPolicy(ctx, storage.PutUserPolicyInput{ + UserName: userName, + PolicyName: "P", + PolicyDocument: policyDocument, + }); err != nil { + t.Fatalf("PutUserPolicy: %v", err) + } + } +} + +func TestIAMServiceStandaloneDeriveSigningKeyAndGetUserAccount(t *testing.T) { + store, sock := standaloneTestServer(t) + createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD) + derivedKey, account, err := client.DeriveSigningKey("AKIAALICE", "", yyyymmdd, "us-east-1", "s3") + if err != nil { + t.Fatalf("DeriveSigningKey: %v", err) + } + + want := sigv4auth.DeriveKey("alicesecret", yyyymmdd, "us-east-1", "s3") + if string(derivedKey) != string(want) { + t.Errorf("derived key = %x, want %x", derivedKey, want) + } + if account.Secret != "" { + t.Errorf("account.Secret should never be populated by the standalone client, got %q", account.Secret) + } + if account.Role != RoleUser { + t.Errorf("account.Role = %v, want %v", account.Role, RoleUser) + } + + // GetUserAccount resolves via the metadata-only endpoint and must agree. + got, err := client.GetUserAccount("AKIAALICE") + if err != nil { + t.Fatalf("GetUserAccount: %v", err) + } + if got.Access != "AKIAALICE" || got.Secret != "" { + t.Errorf("GetUserAccount() = %+v", got) + } +} + +func TestIAMServiceStandaloneGetUserAccountUnknownReturnsErrNoSuchUser(t *testing.T) { + store, sock := standaloneTestServer(t) + _ = store + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + _, err = client.GetUserAccount("AKIADOESNOTEXIST") + if !errors.Is(err, ErrNoSuchUser) { + t.Errorf("GetUserAccount() error = %v, want ErrNoSuchUser", err) + } +} + +func TestIAMServiceStandaloneGetUserAccountRoot(t *testing.T) { + _, sock := standaloneTestServer(t) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + got, err := client.GetUserAccount(standaloneTestRootAccess) + if err != nil { + t.Fatalf("GetUserAccount(root): %v", err) + } + if got.Secret != standaloneTestRootSecret { + t.Errorf("root account should resolve locally with its real secret, got %+v", got) + } +} + +func TestIAMServiceStandaloneEvaluatePolicy(t *testing.T) { + store, sock := standaloneTestServer(t) + createStandaloneTestUser(t, store, "bob", "AKIABOB", "bobsecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + tests := []struct { + name string + action Action + want policyDecision + }{ + {name: "allowed action", action: Action("s3:GetObject"), want: policyDecisionAllow}, + {name: "action with no matching statement", action: Action("s3:PutObject"), want: policyDecisionNoMatch}, + {name: "explicitly denied action", action: Action("s3:DeleteObject"), want: policyDecisionDeny}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eval, err := client.EvaluatePolicy("AKIABOB", "", []Action{tt.action}, []string{"*"}, nil) + if err != nil { + t.Fatalf("EvaluatePolicy: %v", err) + } + if len(eval.Decisions) != 1 || len(eval.Decisions[0]) != 1 || eval.Decisions[0][0] != tt.want { + t.Errorf("Decisions = %v, want [[%v]]", eval.Decisions, tt.want) + } + }) + } +} + +// TestIAMServiceStandaloneEvaluatePolicyBatchesMultipleActions confirms +// several actions are evaluated in a single request, with Decisions +// returned in the same order as the requested actions — the fix for +// identityPolicyDecision previously issuing one round trip per action. +func TestIAMServiceStandaloneEvaluatePolicyBatchesMultipleActions(t *testing.T) { + store, sock := standaloneTestServer(t) + createStandaloneTestUser(t, store, "bob", "AKIABOB", "bobsecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + eval, err := client.EvaluatePolicy("AKIABOB", "", []Action{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, []string{"*"}, nil) + if err != nil { + t.Fatalf("EvaluatePolicy: %v", err) + } + want := []policyDecision{policyDecisionAllow, policyDecisionNoMatch, policyDecisionDeny} + if len(eval.Decisions) != 1 || len(eval.Decisions[0]) != len(want) { + t.Fatalf("Decisions = %v, want [%v]", eval.Decisions, want) + } + for i := range want { + if eval.Decisions[0][i] != want[i] { + t.Errorf("Decisions[0][%d] = %v, want %v", i, eval.Decisions[0][i], want[i]) + } + } +} + +func TestIAMServiceStandaloneMutatingMethodsNotSupported(t *testing.T) { + _, sock := standaloneTestServer(t) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + notSupported := s3err.GetAPIError(s3err.ErrAdminMethodNotSupported) + + if err := client.CreateAccount(Account{}); !errors.Is(err, notSupported) { + t.Errorf("CreateAccount() error = %v, want %v", err, notSupported) + } + if err := client.UpdateUserAccount("x", MutableProps{}); !errors.Is(err, notSupported) { + t.Errorf("UpdateUserAccount() error = %v, want %v", err, notSupported) + } + if err := client.DeleteUserAccount("x"); !errors.Is(err, notSupported) { + t.Errorf("DeleteUserAccount() error = %v, want %v", err, notSupported) + } + if _, err := client.ListUserAccounts(); !errors.Is(err, notSupported) { + t.Errorf("ListUserAccounts() error = %v, want %v", err, notSupported) + } +} + +func TestNewIAMServiceStandaloneRequiresMTLSForTCPEndpoint(t *testing.T) { + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: "127.0.0.1:9443"}) + if err == nil { + t.Fatal("expected an error constructing a TCP-endpoint client without mTLS configured") + } +} + +// TestNewIAMServiceStandaloneDefaultsToRootCredentials confirms this +// client's own signing identity (the credential it signs its private +// requests with) falls back to the gateway's root account when +// Access/Secret aren't explicitly configured — so a deployment doesn't need +// to mint a dedicated IAM identity just for the gateway to talk to its own +// standalone IAM service. +func TestNewIAMServiceStandaloneDefaultsToRootCredentials(t *testing.T) { + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, sock := standaloneTestServer(t) + + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + if client.access != rootAcc.Access { + t.Errorf("access = %q, want root access %q", client.access, rootAcc.Access) + } + if client.secret != rootAcc.Secret { + t.Errorf("secret = %q, want root secret %q", client.secret, rootAcc.Secret) + } + + // Also confirm the client actually works end-to-end when signing with + // the defaulted root identity, not just that the fields were set. + if _, err := client.GetUserAccount(standaloneTestRootAccess); err != nil { + t.Fatalf("GetUserAccount(root) with defaulted signing identity: %v", err) + } +} + +// TestNewIAMServiceStandaloneRespectsExplicitCredentials confirms an +// explicitly configured Access/Secret is used as-is, not overridden by the +// root account's credentials. +func TestNewIAMServiceStandaloneRespectsExplicitCredentials(t *testing.T) { + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, sock := standaloneTestServer(t) + + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{ + Endpoint: sock, + Access: "AKIDCUSTOM", + Secret: "CUSTOMSECRET", + }) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + if client.access != "AKIDCUSTOM" { + t.Errorf("access = %q, want %q", client.access, "AKIDCUSTOM") + } + if client.secret != "CUSTOMSECRET" { + t.Errorf("secret = %q, want %q", client.secret, "CUSTOMSECRET") + } +} diff --git a/auth/iam_vault.go b/auth/iam_vault.go index 6b3c8c08..23a2ae34 100644 --- a/auth/iam_vault.go +++ b/auth/iam_vault.go @@ -261,6 +261,11 @@ func (vt *VaultIAMService) GetUserAccount(access string) (Account, error) { return acc, nil } +// ResolveAccounts returns the subset of accessKeyIDs that do not exist. +func (vt *VaultIAMService) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + return resolveAccountsByLookup(accessKeyIDs, vt.GetUserAccount) +} + func (vt *VaultIAMService) UpdateUserAccount(access string, props MutableProps) error { acc, err := vt.GetUserAccount(access) if err != nil { diff --git a/auth/object_lock.go b/auth/object_lock.go index ba57ebda..e6f72521 100644 --- a/auth/object_lock.go +++ b/auth/object_lock.go @@ -23,6 +23,7 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/s3err" @@ -35,6 +36,48 @@ type BucketLockConfig struct { CreatedAt *time.Time } +// BypassMode says whether, and on whose authority, a request may override a +// GOVERNANCE-mode retention. It exists because the two ways that can happen +// are not equivalent, and collapsing them into one boolean previously let +// root overwrite locked objects it should not have been able to. +type BypassMode int + +const ( + // BypassNone is a request that has not asked to override anything: any + // unexpired retention blocks it outright. + BypassNone BypassMode = iota + + // BypassRequested is a request carrying x-amz-bypass-governance-retention + // — DeleteObject, DeleteObjects, or PutObjectRetention. Root and admin + // may always override a GOVERNANCE retention this way, matching real + // AWS, where the account root can bypass regardless of policy; everyone + // else needs s3:BypassGovernanceRetention. + BypassRequested + + // BypassOverwrite is the gateway's own extension: replacing an existing + // governance-locked object via PutObject, CopyObject or POST Object, + // none of which has a bypass header for a client to send. Because the + // caller never asked to override anything, the permission is required + // from everyone here — root included — and root's blanket bypass above + // deliberately does not apply. (Real S3 has no analogue: it only allows + // object lock on versioned buckets, where an overwrite creates a new + // version rather than replacing a locked one.) + BypassOverwrite +) + +// allowsGovernanceOverride reports whether this mode permits overriding a +// GOVERNANCE retention at all, given the permission to do so. +func (b BypassMode) allowsGovernanceOverride() bool { return b != BypassNone } + +// BypassModeForRequest maps the presence of the client's +// x-amz-bypass-governance-retention header onto a BypassMode. +func BypassModeForRequest(headerPresent bool) BypassMode { + if headerPresent { + return BypassRequested + } + return BypassNone +} + const ( maxObjectLockRetentionDays int32 = 36500 maxObjectLockRetentionYears int32 = 100 @@ -138,8 +181,8 @@ func ParseObjectLockRetentionInputToJSON(input *s3response.PutObjectRetentionInp // IsObjectLockRetentionPutAllowed checks if the object lock retention PUT request // is allowed against the current state of the object lock -func IsObjectLockRetentionPutAllowed(ctx context.Context, be backend.Backend, bucket, object, versionId, userAccess string, input *s3response.PutObjectRetentionInput, bypass bool) error { - ret, err := be.GetObjectRetention(ctx, bucket, object, versionId) +func IsObjectLockRetentionPutAllowed(ctx fiber.Ctx, be backend.Backend, iam IAMService, bucket, object, versionId string, acc Account, input *s3response.PutObjectRetentionInput, bypass bool) error { + ret, err := be.GetObjectRetention(ctx.RequestCtx(), bucket, object, versionId) if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) { // if object lock configuration is not set // allow the retention modification without any checks @@ -155,46 +198,164 @@ func IsObjectLockRetentionPutAllowed(ctx context.Context, be backend.Backend, bu return err } - if retention.Mode == input.Mode { - // if retention mode is the same - // the operation is allowed + // Pushing the date further out only ever strengthens the lock, so it + // needs nothing beyond s3:PutObjectRetention — in either mode. Anything + // that weakens it, an earlier date or a mode change, does not. + // + // A stored retention carrying no date can't be compared, so it counts as + // weakenable rather than being assumed an extension — the fail-closed + // direction. + isExtension := retention.Mode == input.Mode && + retention.RetainUntilDate != nil && + !input.RetainUntilDate.Time.Before(*retention.RetainUntilDate) + if isExtension { return nil } if retention.Mode == types.ObjectLockRetentionModeCompliance { - // COMPLIANCE mode is by definition not allowed to modify - debuglogger.Logf("object lock retention change request from 'COMPLIANCE' to 'GOVERNANCE' is not allowed") + // COMPLIANCE is absolute until it expires: it can be extended (above) + // but never shortened, and never downgraded to GOVERNANCE — by + // anyone, with any permission, including the account root. That + // immutability is the whole point of the mode, and real AWS rejects + // a shortening PutObjectRetention on a COMPLIANCE object even with + // the bypass header present. + debuglogger.Logf("weakening a 'COMPLIANCE' object lock retention is not allowed") return s3err.GetAPIError(s3err.ErrObjectLocked) } if !bypass { // if x-amz-bypass-governance-retention is not provided // return error: object is locked - debuglogger.Logf("object lock retention mode change is not allowed and bypass governence is not forced") + debuglogger.Logf("weakening a 'GOVERNANCE' object lock retention is not allowed without the bypass governance header") return s3err.GetAPIError(s3err.ErrObjectLocked) } - // the last case left, when user tries to chenge - // from 'GOVERNANCE' to 'COMPLIANCE' with - // 'x-amz-bypass-governance-retention' header - // first we need to check if user has 's3:BypassGovernanceRetention' - policy, err := be.GetBucketPolicy(ctx, bucket) - if err != nil { - // if it fails to get the policy, return object is locked - debuglogger.Logf("failed to get the bucket policy: %v", err) - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - err = VerifyBucketPolicy(policy, userAccess, bucket, object, be.NormalizeObjectKey, BypassGovernanceRetentionAction) - if err != nil { - // if user doesn't have "s3:BypassGovernanceRetention" permission - // return object is locked - debuglogger.Logf("the user is missing 's3:BypassGovernanceRetention' permission") - return s3err.GetAPIError(s3err.ErrObjectLocked) + // What's left is weakening a GOVERNANCE retention — shortening its date, + // or switching it to COMPLIANCE — with the bypass header. That needs + // s3:BypassGovernanceRetention, via the bucket policy and/or (when + // configured) the IAM identity policy. + if err := verifyBypassGovernancePermission(ctx.RequestCtx(), be, iam, acc, bucket, object, BypassRequested, false, requestConditionContext(ctx)); err != nil { + debuglogger.Logf("the user is missing 's3:BypassGovernanceRetention' permission: %v", err) + return err } return nil } +// verifyBypassGovernancePermission decides whether acc may use +// x-amz-bypass-governance-retention to override a GOVERNANCE-mode lock on +// bucket/key. For a public (anonymous) request it consults only the +// bucket's public policy grant, wrapped in the generic ErrObjectLocked. For +// an authenticated request it combines the bucket policy decision with an +// identity-policy decision from iam when it implements PolicyEvaluator +// (currently only the standalone IAM service client), using the same +// explicit-deny-wins precedence as VerifyAccess. Unlike the "no header" +// case, a failed permission check here is reported as the specific +// AccessDenied error naming s3:BypassGovernanceRetention, not the generic +// "object protected by object lock" message — that message is reserved for +// when the bypass header itself is absent, or for backends with no +// identity-policy layer at all, where it preserves the existing behavior. +func verifyBypassGovernancePermission(ctx context.Context, be backend.Backend, iam IAMService, acc Account, bucket, key string, mode BypassMode, isBucketPublic bool, condCtx map[string][]string) error { + // Root and admin override a GOVERNANCE retention unconditionally when + // the client actually asked to — matching real AWS, where the account + // root can bypass whatever the policies say. + // + // This deliberately does not extend to BypassOverwrite: there the + // caller never requested a bypass (no S3 write API has a header for + // it), so there is nothing to grant root on their behalf, and letting + // it through would mean root silently replacing locked objects. See + // BypassMode. + if mode == BypassRequested && acc.Role == RoleAdmin { + return nil + } + + if isBucketPublic { + policy, err := be.GetBucketPolicy(ctx, bucket) + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { + return s3err.GetAPIError(s3err.ErrObjectLocked) + } + if err != nil { + return err + } + if err := VerifyPublicBucketPolicy(policy, bucket, key, condCtx, be.NormalizeObjectKey, BypassGovernanceRetentionAction); err != nil { + return s3err.GetAPIError(s3err.ErrObjectLocked) + } + return nil + } + + var resourceDecision policyDecision + policy, err := be.GetBucketPolicy(ctx, bucket) + switch { + case errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)): + resourceDecision = policyDecisionNoMatch + case err != nil: + return err + default: + resourceDecision, _, err = verifyBucketPolicy(policy, acc.Access, bucket, key, condCtx, be.NormalizeObjectKey, BypassGovernanceRetentionAction) + if err != nil { + return err + } + } + + resourceArn := objectPolicyArn(bucket, key, be.NormalizeObjectKey) + + if resourceDecision == policyDecisionDeny { + return s3err.GetExplicitDenyAccessErr(acc.Access, string(BypassGovernanceRetentionAction), resourceArn, "a resource-based policy") + } + + pe, hasPolicyEvaluator := iam.(PolicyEvaluator) + + // Only BypassOverwrite reaches here as root — BypassRequested already + // returned above. Root has no identity policy to evaluate: with the + // standalone IAM backend it is not an IAM user at all, so asking that + // service about it would fail with ErrNoSuchUser rather than return a + // decision. It therefore falls back to the bucket-policy decision alone, + // exactly as a backend with no identity-policy layer does, and so still + // needs an explicit grant to replace a locked object. + if !hasPolicyEvaluator || acc.Role == RoleAdmin { + // No identity-policy layer for this backend: preserve today's exact + // behavior for every internal/LDAP/Vault/IPA deployment. + if resourceDecision == policyDecisionAllow { + return nil + } + return s3err.GetAPIError(s3err.ErrObjectLocked) + } + + identity, err := identityPolicyDecisions(pe, AccessOptions{ + Acc: acc, + Bucket: bucket, + Object: key, + Actions: []Action{BypassGovernanceRetentionAction}, + }, []string{key}, be.NormalizeObjectKey, condCtx) + if err != nil { + return err + } + + identityDecision := identity.Decisions[0].Decision + sessionDenies := identity.HasSessionPolicy && identity.SessionDecisions[0].Decision == policyDecisionDeny + // A session policy filters this permission the same way it filters any + // other: it can only take away what the role or the bucket policy grants. + sessionWithholds := identity.HasSessionPolicy && identity.SessionDecisions[0].Decision != policyDecisionAllow + + if identityDecision == policyDecisionDeny || sessionDenies { + principal := identity.PrincipalArn + if principal == "" { + principal = acc.Access + } + return s3err.GetExplicitDenyAccessErr(principal, string(BypassGovernanceRetentionAction), resourceArn, "an identity-based policy") + } + if !sessionWithholds && + (resourceDecision == policyDecisionAllow || identityDecision == policyDecisionAllow) { + return nil + } + + principal := identity.PrincipalArn + if principal == "" { + principal = acc.Access + } + return s3err.GetImplicitDenyAccessErr(principal, string(BypassGovernanceRetentionAction), resourceArn) +} + func ParseObjectLockRetentionOutput(input []byte) (*types.ObjectLockRetention, error) { var retention types.ObjectLockRetention if err := json.Unmarshal(input, &retention); err != nil { @@ -221,35 +382,74 @@ func ParseObjectLegalHoldOutput(status *bool) *s3response.GetObjectLegalHoldResu } } -func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects []types.ObjectIdentifier, bypass, isBucketPublic bool, be backend.Backend, isOverwrite bool) error { +// CheckObjectAccess enforces the object locks protecting objects, for the +// single-object write paths. The multi-object delete path uses +// VerifyObjectsAccess instead, which folds this together with the +// authorization check into one pass. +func CheckObjectAccess(ctx fiber.Ctx, bucket string, acc Account, objects []types.ObjectIdentifier, bypass BypassMode, isBucketPublic bool, be backend.Backend, iam IAMService, isOverwrite bool) error { + rctx := ctx.RequestCtx() + state, err := loadObjectLockState(rctx, be, bucket, isOverwrite) + if err != nil || !state.applies { + return err + } + + condCtx := requestConditionContext(ctx) + for _, obj := range objects { + if err := state.checkObject(rctx, be, iam, acc, bucket, obj, bypass, isBucketPublic, condCtx); err != nil { + return err + } + } + + return nil +} + +// objectLockState is the bucket-level object-lock configuration a request is +// evaluated against, resolved once so a request naming many objects doesn't +// re-fetch it per key. +type objectLockState struct { + // applies is false when nothing about this bucket can block the request: + // object lock is off, unconfigured, or the write creates a new version + // rather than replacing anything. + applies bool + // defaultRetention is the bucket's default retention, only set when it + // is configured and still in force. + defaultRetention *types.DefaultRetention + // versioningEnabled makes a delete without a version id a new delete + // marker, which no retention protects against. + versioningEnabled bool +} + +func loadObjectLockState(ctx context.Context, be backend.Backend, bucket string, isOverwrite bool) (objectLockState, error) { + var state objectLockState + if isOverwrite { // if bucket versioning is enabled, any overwrite request // should be enabled, as it leads to a new object version // creation res, err := be.GetBucketVersioning(ctx, bucket) if err == nil && res.Status != nil && *res.Status == types.BucketVersioningStatusEnabled { - return nil + return state, nil } } + data, err := be.GetObjectLockConfiguration(ctx, bucket) if err != nil { if errors.Is(err, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)) { - return nil + return state, nil } - return err + return state, err } var bucketLockConfig BucketLockConfig if err := json.Unmarshal(data, &bucketLockConfig); err != nil { - return fmt.Errorf("parse object lock config: %w", err) + return state, fmt.Errorf("parse object lock config: %w", err) } if !bucketLockConfig.Enabled { - return nil + return state, nil } - - checkDefaultRetention := false + state.applies = true if bucketLockConfig.DefaultRetention != nil && bucketLockConfig.CreatedAt != nil { expirationDate := *bucketLockConfig.CreatedAt @@ -261,130 +461,114 @@ func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects [ } if expirationDate.After(time.Now()) { - checkDefaultRetention = true + state.defaultRetention = bucketLockConfig.DefaultRetention } } - var versioningEnabled bool vers, err := be.GetBucketVersioning(ctx, bucket) if err == nil && vers.Status != nil { - versioningEnabled = *vers.Status == types.BucketVersioningStatusEnabled + state.versioningEnabled = *vers.Status == types.BucketVersioningStatusEnabled } - for _, obj := range objects { - var key, versionId string - if obj.Key != nil { - key = *obj.Key - } - if obj.VersionId != nil { - versionId = *obj.VersionId - } - // if bucket versioning is enabled and versionId isn't provided - // no lock check is needed, as it leads to a new delete marker creation - if versioningEnabled && versionId == "" { - continue - } - checkRetention := true - retentionData, err := be.GetObjectRetention(ctx, bucket, key, versionId) - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) { - continue - } - // the object is a delete marker, if a `MethodNotAllowed` error is returned - // no object lock check is needed - if errors.Is(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)) { - continue - } - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) { - checkRetention = false - } - if err != nil && checkRetention { + return state, nil +} + +// checkObject reports whether one object's retention or legal hold blocks +// this request. A nil error means this object is writable; it says nothing +// about any other object in the same request. +func (s objectLockState) checkObject(ctx context.Context, be backend.Backend, iam IAMService, acc Account, bucket string, obj types.ObjectIdentifier, bypass BypassMode, isBucketPublic bool, condCtx map[string][]string) error { + var key, versionId string + if obj.Key != nil { + key = *obj.Key + } + if obj.VersionId != nil { + versionId = *obj.VersionId + } + + // if bucket versioning is enabled and versionId isn't provided + // no lock check is needed, as it leads to a new delete marker creation + if s.versioningEnabled && versionId == "" { + return nil + } + + checkRetention := true + retentionData, err := be.GetObjectRetention(ctx, bucket, key, versionId) + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) { + return nil + } + // the object is a delete marker, if a `MethodNotAllowed` error is returned + // no object lock check is needed + if errors.Is(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)) { + return nil + } + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) { + checkRetention = false + } + if err != nil && checkRetention { + return err + } + + if checkRetention { + retention, err := ParseObjectLockRetentionOutput(retentionData) + if err != nil { return err } - if checkRetention { - retention, err := ParseObjectLockRetentionOutput(retentionData) - if err != nil { - return err + if retention.Mode != "" && retention.RetainUntilDate != nil { + // An expired retention protects nothing, and an object's own + // retention supersedes the bucket default, so this object is + // past its lock. Note this also skips the legal-hold check + // below, preserving long-standing behavior; it returns for + // this object only, where the same statement previously + // short-circuited the caller's whole request and let every + // remaining object through unchecked. + if retention.RetainUntilDate.Before(time.Now()) { + return nil } - if retention.Mode != "" && retention.RetainUntilDate != nil { - if retention.RetainUntilDate.Before(time.Now()) { - // if the object retention is expired, the object - // is allowed for write operations(delete, modify) - return nil - } - - switch retention.Mode { - case types.ObjectLockRetentionModeGovernance: - if !bypass { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } else { - policy, err := be.GetBucketPolicy(ctx, bucket) - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - if err != nil { - return err - } - if isBucketPublic { - err = VerifyPublicBucketPolicy(policy, bucket, key, be.NormalizeObjectKey, BypassGovernanceRetentionAction) - } else { - err = VerifyBucketPolicy(policy, userAccess, bucket, key, be.NormalizeObjectKey, BypassGovernanceRetentionAction) - } - if err != nil { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - } - case types.ObjectLockRetentionModeCompliance: - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - } - } - - checkLegalHold := true - - status, err := be.GetObjectLegalHold(ctx, bucket, key, versionId) - if err != nil { - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) { - continue - } - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) { - checkLegalHold = false - } else { + if err := s.checkRetentionMode(ctx, be, iam, acc, bucket, key, retention.Mode, bypass, isBucketPublic, condCtx); err != nil { return err } } + } - if checkLegalHold && *status { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } + checkLegalHold := true - if checkDefaultRetention { - switch bucketLockConfig.DefaultRetention.Mode { - case types.ObjectLockRetentionModeGovernance: - if !bypass { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } else { - policy, err := be.GetBucketPolicy(ctx, bucket) - if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - if err != nil { - return err - } - if isBucketPublic { - err = VerifyPublicBucketPolicy(policy, bucket, key, be.NormalizeObjectKey, BypassGovernanceRetentionAction) - } else { - err = VerifyBucketPolicy(policy, userAccess, bucket, key, be.NormalizeObjectKey, BypassGovernanceRetentionAction) - } - if err != nil { - return s3err.GetAPIError(s3err.ErrObjectLocked) - } - } - case types.ObjectLockRetentionModeCompliance: - return s3err.GetAPIError(s3err.ErrObjectLocked) - } + status, err := be.GetObjectLegalHold(ctx, bucket, key, versionId) + if err != nil { + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) { + return nil } + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) { + checkLegalHold = false + } else { + return err + } + } + + if checkLegalHold && *status { + return s3err.GetAPIError(s3err.ErrObjectLocked) + } + + if s.defaultRetention != nil { + return s.checkRetentionMode(ctx, be, iam, acc, bucket, key, s.defaultRetention.Mode, bypass, isBucketPublic, condCtx) + } + + return nil +} + +// checkRetentionMode applies one retention mode's rule: COMPLIANCE blocks +// unconditionally, GOVERNANCE blocks unless the request both asked to +// override it and is permitted to. +func (s objectLockState) checkRetentionMode(ctx context.Context, be backend.Backend, iam IAMService, acc Account, bucket, key string, mode types.ObjectLockRetentionMode, bypass BypassMode, isBucketPublic bool, condCtx map[string][]string) error { + switch mode { + case types.ObjectLockRetentionModeGovernance: + if !bypass.allowsGovernanceOverride() { + return s3err.GetAPIError(s3err.ErrObjectLocked) + } + return verifyBypassGovernancePermission(ctx, be, iam, acc, bucket, key, bypass, isBucketPublic, condCtx) + case types.ObjectLockRetentionModeCompliance: + return s3err.GetAPIError(s3err.ErrObjectLocked) } return nil diff --git a/auth/object_lock_test.go b/auth/object_lock_test.go new file mode 100644 index 00000000..65d54107 --- /dev/null +++ b/auth/object_lock_test.go @@ -0,0 +1,318 @@ +// 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 auth + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" +) + +// TestVerifyBypassGovernancePermission_IdentityAllowNoBucketPolicy is the +// same-account fix this function exists for: an IAM identity policy Allow +// is sufficient to use x-amz-bypass-governance-retention even when the +// bucket has no policy at all. The old bucket-policy-only check treated "no +// bucket policy" as an immediate ErrObjectLocked, never even consulting the +// identity policy. +func TestVerifyBypassGovernancePermission_IdentityAllowNoBucketPolicy(t *testing.T) { + be := noBucketPolicyBackend{} + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + assert.NoError(t, err) +} + +// TestVerifyBypassGovernancePermission_ResourceAllowIdentitySilent is the +// reverse: a bucket policy Allow is sufficient when the identity policy has +// no opinion on the action, but the identity policy must still be +// consulted (not skipped) so an explicit Deny there can override it. +func TestVerifyBypassGovernancePermission_ResourceAllowIdentitySilent(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:BypassGovernanceRetention", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + + err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + assert.NoError(t, err) + assert.Len(t, pe.calls, 1, "identity policy must be consulted even though the bucket policy already allows") +} + +// TestVerifyBypassGovernancePermission_IdentityExplicitDenyOverridesResourceAllow +// is the explicit-deny-wins case: a bucket policy Allow does not save a +// bypass request the caller's own identity policy explicitly denies. AWS +// reports this as a specific AccessDenied naming +// s3:BypassGovernanceRetention, not the generic "object protected by object +// lock" message. +func TestVerifyBypassGovernancePermission_IdentityExplicitDenyOverridesResourceAllow(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:BypassGovernanceRetention", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionDeny) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "s3:BypassGovernanceRetention") + assert.Contains(t, apiErr.Description, "with an explicit deny in an identity-based policy") +} + +// TestVerifyBypassGovernancePermission_ResourceExplicitDenyOverridesIdentityAllow +// is the reverse: an identity policy Allow does not save a bypass request +// the bucket policy explicitly denies, and the resource-level Deny +// short-circuits before the identity policy is even consulted. +func TestVerifyBypassGovernancePermission_ResourceExplicitDenyOverridesIdentityAllow(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Deny", + "Principal": "testuser", + "Action": "s3:BypassGovernanceRetention", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "with an explicit deny in a resource-based policy") + assert.Empty(t, pe.calls, "a resource-level explicit deny should short-circuit before consulting the identity policy") +} + +// TestVerifyBypassGovernancePermission_ImplicitDenyWhenNeitherAllows: with +// no bucket policy and no identity-policy grant, AWS denies with "because +// no identity-based policy allows the s3:BypassGovernanceRetention action" +// — the same implicit-deny shape VerifyAccess uses for ordinary actions. +func TestVerifyBypassGovernancePermission_ImplicitDenyWhenNeitherAllows(t *testing.T) { + be := noBucketPolicyBackend{} + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + pe.principalArn = "arn:aws:iam::000000000000:user/testuser" + + err := verifyBypassGovernancePermission(context.Background(), be, pe, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + apiErr := requireAccessDeniedAPIError(t, err) + assert.Contains(t, apiErr.Description, "arn:aws:iam::000000000000:user/testuser") + assert.Contains(t, apiErr.Description, "because no identity-based policy allows the s3:BypassGovernanceRetention action") +} + +// TestVerifyBypassGovernancePermission_NoPolicyEvaluatorPreservesGenericMessage +// confirms backends with no identity-policy layer (every backend except the +// standalone IAM service) are unaffected: the generic ErrObjectLocked stays +// exactly as before when there is no bucket policy to grant the bypass. +func TestVerifyBypassGovernancePermission_NoPolicyEvaluatorPreservesGenericMessage(t *testing.T) { + be := noBucketPolicyBackend{} + iam := NewIAMServiceSingle(Account{}) + + err := verifyBypassGovernancePermission(context.Background(), be, iam, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err) +} + +// TestVerifyBypassGovernancePermission_NoPolicyEvaluatorBucketPolicyAllowStillWorks +// pins that, without a PolicyEvaluator, a bucket policy Allow alone is still +// sufficient — the pre-existing (bucket-policy-only) behavior. +func TestVerifyBypassGovernancePermission_NoPolicyEvaluatorBucketPolicyAllowStillWorks(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "testuser", + "Action": "s3:BypassGovernanceRetention", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + iam := NewIAMServiceSingle(Account{}) + + err := verifyBypassGovernancePermission(context.Background(), be, iam, Account{Access: "testuser"}, "bucket", "key.txt", BypassRequested, false, nil) + + assert.NoError(t, err) +} + +// TestVerifyBypassGovernancePermission_PublicBucketAllowed and +// TestVerifyBypassGovernancePermission_PublicBucketDenied confirm the +// isBucketPublic branch (anonymous requests, evaluated only against the +// bucket's public policy grant, wrapped in the generic ErrObjectLocked) is +// unchanged by this refactor. +func TestVerifyBypassGovernancePermission_PublicBucketAllowed(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": "s3:BypassGovernanceRetention", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + + err := verifyBypassGovernancePermission(context.Background(), be, nil, Account{}, "bucket", "key.txt", BypassRequested, true, nil) + + assert.NoError(t, err) +} + +func TestVerifyBypassGovernancePermission_PublicBucketDenied(t *testing.T) { + be := &publicBucketPolicyBackend{ + policy: []byte(`{ + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/*" + }] + }`), + } + + err := verifyBypassGovernancePermission(context.Background(), be, nil, Account{}, "bucket", "key.txt", BypassRequested, true, nil) + + assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err) +} + +// TestVerifyBypassGovernancePermission_RootBypassesOnlyWhenRequested pins the +// asymmetry between the two ways a governance retention can be overridden. +// +// Root bypasses unconditionally when the client actually sent +// x-amz-bypass-governance-retention, matching real AWS, where the account +// root can bypass regardless of policy. It does not get that on the +// overwrite path, where no client asked for anything and letting root +// through would mean silently replacing a locked object. +func TestVerifyBypassGovernancePermission_RootBypassesOnlyWhenRequested(t *testing.T) { + root := Account{Access: "root", Role: RoleAdmin} + + // No bucket policy and no identity policy: the only thing that could + // possibly permit this is root's own status. + be := &publicBucketPolicyBackend{policy: []byte(`{"Statement":[]}`)} + pe := newMockPolicyEvaluator(policyDecisionNoMatch) + + err := verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassRequested, false, nil) + assert.NoError(t, err, "root must bypass a governance retention it explicitly asked to bypass") + + err = verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassOverwrite, false, nil) + assert.Error(t, err, "root must not silently overwrite a governance-locked object: no bypass was requested") + + err = verifyBypassGovernancePermission(context.Background(), be, pe, root, "bucket", "key.txt", BypassNone, false, nil) + assert.Error(t, err, "root must not bypass when the request did not ask to") +} + +// TestVerifyBypassGovernancePermission_NonRootStillNeedsPermission confirms +// the root shortcut is exactly that, and does not leak to ordinary users. +func TestVerifyBypassGovernancePermission_NonRootStillNeedsPermission(t *testing.T) { + user := Account{Access: "testuser", Role: RoleUser} + be := &publicBucketPolicyBackend{policy: []byte(`{"Statement":[]}`)} + + err := verifyBypassGovernancePermission(context.Background(), be, + newMockPolicyEvaluator(policyDecisionNoMatch), user, "bucket", "key.txt", BypassRequested, false, nil) + assert.Error(t, err, "a plain user with no grant anywhere must not bypass") + + err = verifyBypassGovernancePermission(context.Background(), be, + newMockPolicyEvaluator(policyDecisionAllow), user, "bucket", "key.txt", BypassRequested, false, nil) + assert.NoError(t, err, "an identity-policy Allow grants the bypass") +} + +// TestIsObjectLockRetentionPutAllowed_WeakeningRules covers which retention +// rewrites need s3:BypassGovernanceRetention and which need nothing. +// +// Extending a GOVERNANCE or COMPLIANCE retention, or rewriting it with the +// identical date, succeeds with no bypass header, while shortening either +// one without the header fails with "Access Denied because object +// protected by object lock." A COMPLIANCE retention cannot be weakened at +// all, even with the header. +func TestIsObjectLockRetentionPutAllowed_WeakeningRules(t *testing.T) { + now := time.Now() + stored := now.Add(time.Hour) + + tests := []struct { + name string + mode types.ObjectLockRetentionMode + newMode types.ObjectLockRetentionMode + newDate time.Time + bypass bool + wantAllow bool + }{ + {name: "governance extended", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored.Add(time.Hour), wantAllow: true}, + {name: "governance same date", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored, wantAllow: true}, + {name: "governance shortened without bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: now.Add(time.Minute)}, + {name: "governance shortened with bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeGovernance, newDate: now.Add(time.Minute), bypass: true, wantAllow: true}, + {name: "compliance extended", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored.Add(time.Hour), wantAllow: true}, + {name: "compliance shortened without bypass", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: now.Add(time.Minute)}, + {name: "compliance shortened with bypass", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeCompliance, newDate: now.Add(time.Minute), bypass: true}, + {name: "compliance downgraded to governance", mode: types.ObjectLockRetentionModeCompliance, newMode: types.ObjectLockRetentionModeGovernance, newDate: stored.Add(time.Hour), bypass: true}, + {name: "governance upgraded to compliance with bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored, bypass: true, wantAllow: true}, + {name: "governance upgraded to compliance without bypass", mode: types.ObjectLockRetentionModeGovernance, newMode: types.ObjectLockRetentionModeCompliance, newDate: stored}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retention, err := json.Marshal(types.ObjectLockRetention{Mode: tt.mode, RetainUntilDate: &stored}) + assert.NoError(t, err) + + be := &objectRetentionBackend{retention: retention} + // A permissive evaluator, so any denial below is the retention + // rule talking rather than a missing permission. + pe := newMockPolicyEvaluator(policyDecisionAllow) + + err = IsObjectLockRetentionPutAllowed(testFiberCtx(t), be, pe, "bucket", "key.txt", "", + Account{Access: "testuser", Role: RoleUser}, + &s3response.PutObjectRetentionInput{Mode: tt.newMode, RetainUntilDate: s3response.AmzDate{Time: tt.newDate}}, + tt.bypass) + + if tt.wantAllow { + assert.NoError(t, err) + } else { + assert.Equal(t, s3err.GetAPIError(s3err.ErrObjectLocked), err) + } + }) + } +} + +// objectRetentionBackend serves one canned object retention. +type objectRetentionBackend struct { + backend.BackendUnsupported + retention []byte +} + +func (b *objectRetentionBackend) GetObjectRetention(_ context.Context, _, _, _ string) ([]byte, error) { + return b.retention, nil +} + +func (b *objectRetentionBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy) +} diff --git a/auth/post_policy.go b/auth/post_policy.go index 3dc5ba86..747f4acf 100644 --- a/auth/post_policy.go +++ b/auth/post_policy.go @@ -346,6 +346,14 @@ func lookupField(in PostPolicyEvalInput, field string) (string, bool) { // isIgnoredCoverageField reports whether a submitted field is exempt from the // POST policy's field coverage requirement. +// +// x-amz-security-token is deliberately NOT exempt, despite being generated +// by the SDK rather than chosen by the form author. The POST signature +// covers only the base64 policy document, so an uncovered token field would +// be completely unbound — anyone could swap in another session's token. +// Requiring the policy to declare a condition for it is what binds it, and +// is what real AWS requires as well; the SDK's POST presigner emits the +// matching condition for exactly this reason. func isIgnoredCoverageField(field string) bool { return field == "file" || field == "policy" || diff --git a/auth/signing_key_provider.go b/auth/signing_key_provider.go new file mode 100644 index 00000000..aff059f7 --- /dev/null +++ b/auth/signing_key_provider.go @@ -0,0 +1,119 @@ +// 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 auth + +import "github.com/versity/versitygw/internal/sigv4auth" + +// SigningKeyProvider is implemented by IAM backends that can compute a +// SigV4 derived signing key (kSigning) without ever revealing the account's +// underlying secret to this process — currently only the standalone IAM +// service client (IAMServiceStandalone). Callers that resolve an +// IAMService's derived key type-assert for this interface first and fall +// back to fetching the account's secret via GetUserAccount and deriving the +// key locally (sigv4auth.DeriveKey) when it isn't implemented, so every +// other backend (internal, LDAP, Vault, IPA, S3) is unaffected. +// +// date/region/service are the request's credential-scope components +// (yyyymmdd/region/service, matching sigv4auth.DeriveKey's parameters). +// sessionToken is the request's X-Amz-Security-Token, required when access +// is a temporary (ASIA…) key and empty otherwise. +// +// The returned Account never has Secret populated. Returns ErrNoSuchUser if +// access does not exist (matching IAMService.GetUserAccount), or +// ErrInvalidSessionToken if the token is missing, wrong, or paired with a +// permanent access key. +type SigningKeyProvider interface { + DeriveSigningKey(access, sessionToken, date, region, service string) ([]byte, Account, error) +} + +// PolicyEvaluation is what a PolicyEvaluator reports for a batch of +// resources and actions evaluated together in a single request. +// +// Decisions[i][j] is the tri-state decision for resources[i] and actions[j], +// in the order both were given to EvaluatePolicy. PrincipalArn is the +// (best-effort) resolved principal ARN, shared across the whole batch since +// one call always evaluates a single identity — it is used only to build an +// AWS-shaped Deny message, and is "" when it can't be resolved, in which +// case the caller falls back to the access key. +// +// SessionDecisions is the same matrix evaluated against the caller's session +// policy alone, meaningful only when HasSessionPolicy is set. A session +// policy filters everything the session can do, including what the bucket +// policy grants it, so it cannot be folded into Decisions — which describe +// only the identity (user or role) policies. +type PolicyEvaluation struct { + Decisions [][]policyDecision + SessionDecisions [][]policyDecision + HasSessionPolicy bool + PrincipalArn string +} + +// PolicyEvaluator is implemented by IAM backends that enforce IAM identity +// (user/role/session) policies against S3 requests — currently only the +// standalone IAM service client. VerifyAccess type-asserts for this +// interface and, when present, combines its tri-state decision with the +// bucket's own policy/ACL decision using AWS's real precedence: an explicit +// Deny from either source wins outright, otherwise either source's Allow is +// independently sufficient; backends without it are unaffected — there is +// no identity-policy layer for them. +// +// The full actions × resources matrix is evaluated in a single batched +// request rather than one round trip per cell. Both dimensions are really +// used: a copy checks several actions (its source's and destination's), and +// a batch delete checks several resources (one object ARN per key, up to +// 1000 of them). A round trip per cell would multiply request latency for +// no benefit, since one request can carry the whole matrix. +// +// condition carries only the condition keys the gateway itself can observe +// from the request (aws:SourceIp, aws:CurrentTime, aws:SecureTransport, …); +// the identity-derived keys are filled in by the implementation, which is +// the only side that knows who the access key belongs to. +type PolicyEvaluator interface { + EvaluatePolicy(access, sessionToken string, actions []Action, resources []string, condition map[string][]string) (PolicyEvaluation, error) +} + +// ResolveDerivedKey resolves access's SigV4 derived signing key (kSigning) +// and account metadata for date/region/service. root is special-cased +// locally — its secret is already known to this process either way, so +// there's no reason to round-trip it through iam. Otherwise, if iam +// implements SigningKeyProvider, the key is fetched from it directly and +// the account's secret never enters this process; otherwise iam's account +// is resolved via GetUserAccount and the key is derived locally from its +// secret, preserving today's behavior for every backend that doesn't +// implement SigningKeyProvider (internal, LDAP, Vault, IPA, S3). +// +// sessionToken is the request's X-Amz-Security-Token, or "" when it carries +// none. A temporary (ASIA…) access key, or a token paired with a permanent +// one, is only meaningful to a SigningKeyProvider backend: no other backend +// can mint a session, so for them either shape is rejected as +// ErrInvalidSessionToken rather than silently resolving to something else. +func ResolveDerivedKey(iam IAMService, root Account, access, sessionToken, date, region, service string) ([]byte, Account, error) { + if access == root.Access { + if sessionToken != "" { + return nil, Account{}, ErrInvalidSessionToken + } + return sigv4auth.DeriveKey(root.Secret, date, region, service), root, nil + } + if skp, ok := iam.(SigningKeyProvider); ok { + return skp.DeriveSigningKey(access, sessionToken, date, region, service) + } + if sessionToken != "" || sigv4auth.IsTempAccessKeyID(access) { + return nil, Account{}, ErrInvalidSessionToken + } + account, err := iam.GetUserAccount(access) + if err != nil { + return nil, Account{}, err + } + return sigv4auth.DeriveKey(account.Secret, date, region, service), account, nil +} diff --git a/aws/LICENSE.txt b/aws/LICENSE.txt deleted file mode 100644 index d6456956..00000000 --- a/aws/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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. diff --git a/aws/NOTICE.txt b/aws/NOTICE.txt deleted file mode 100644 index 5cc3afc1..00000000 --- a/aws/NOTICE.txt +++ /dev/null @@ -1,4 +0,0 @@ -AWS SDK for Go -Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. -Copyright 2014-2015 Stripe, Inc. -Copyright 2024 Versity Software diff --git a/aws/README.md b/aws/README.md deleted file mode 100644 index fa2d6980..00000000 --- a/aws/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# AWS SDK Go v2 - -This directory contains code from the [AWS SDK Go v2](https://github.com/aws/aws-sdk-go-v2) repository, modified in accordance with the Apache 2.0 License. - -## Description - -The AWS SDK Go v2 is a collection of libraries and tools that enable developers to build applications that integrate with various AWS services. This directory and below contains modified code from the original repository, tailored to suit versitygw specific requirements. - -## License - -The code in this directory is licensed under the Apache 2.0 License. Please refer to the [LICENSE](./LICENSE) file for more information. diff --git a/aws/internal/awstesting/unit/unit.go b/aws/internal/awstesting/unit/unit.go deleted file mode 100644 index 989be144..00000000 --- a/aws/internal/awstesting/unit/unit.go +++ /dev/null @@ -1,61 +0,0 @@ -// Package unit performs initialization and validation for unit tests -package unit - -import ( - "context" - "crypto/rsa" - "math/big" - - "github.com/aws/aws-sdk-go-v2/aws" -) - -func init() { - config = aws.Config{} - config.Region = "mock-region" - config.Credentials = StubCredentialsProvider{} -} - -// StubCredentialsProvider provides a stub credential provider that returns -// static credentials that never expire. -type StubCredentialsProvider struct{} - -// Retrieve satisfies the CredentialsProvider interface. Returns stub -// credential value, and never error. -func (StubCredentialsProvider) Retrieve(context.Context) (aws.Credentials, error) { - return aws.Credentials{ - AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION", - Source: "unit test credentials", - }, nil -} - -var config aws.Config - -// Config returns a copy of the mock configuration for unit tests. -func Config() aws.Config { return config.Copy() } - -// RSAPrivateKey is used for testing functionality that requires some -// sort of private key. Taken from crypto/rsa/rsa_test.go -// -// Credit to golang 1.11 -var RSAPrivateKey = &rsa.PrivateKey{ - PublicKey: rsa.PublicKey{ - N: fromBase10("14314132931241006650998084889274020608918049032671858325988396851334124245188214251956198731333464217832226406088020736932173064754214329009979944037640912127943488972644697423190955557435910767690712778463524983667852819010259499695177313115447116110358524558307947613422897787329221478860907963827160223559690523660574329011927531289655711860504630573766609239332569210831325633840174683944553667352219670930408593321661375473885147973879086994006440025257225431977751512374815915392249179976902953721486040787792801849818254465486633791826766873076617116727073077821584676715609985777563958286637185868165868520557"), - E: 3, - }, - D: fromBase10("9542755287494004433998723259516013739278699355114572217325597900889416163458809501304132487555642811888150937392013824621448709836142886006653296025093941418628992648429798282127303704957273845127141852309016655778568546006839666463451542076964744073572349705538631742281931858219480985907271975884773482372966847639853897890615456605598071088189838676728836833012254065983259638538107719766738032720239892094196108713378822882383694456030043492571063441943847195939549773271694647657549658603365629458610273821292232646334717612674519997533901052790334279661754176490593041941863932308687197618671528035670452762731"), - Primes: []*big.Int{ - fromBase10("130903255182996722426771613606077755295583329135067340152947172868415809027537376306193179624298874215608270802054347609836776473930072411958753044562214537013874103802006369634761074377213995983876788718033850153719421695468704276694983032644416930879093914927146648402139231293035971427838068945045019075433"), - fromBase10("109348945610485453577574767652527472924289229538286649661240938988020367005475727988253438647560958573506159449538793540472829815903949343191091817779240101054552748665267574271163617694640513549693841337820602726596756351006149518830932261246698766355347898158548465400674856021497190430791824869615170301029"), - }, -} - -// Taken from crypto/rsa/rsa_test.go -// -// Credit to golang 1.11 -func fromBase10(base10 string) *big.Int { - i, ok := new(big.Int).SetString(base10, 10) - if !ok { - panic("bad number: " + base10) - } - return i -} diff --git a/aws/signer/internal/v4/cache.go b/aws/signer/internal/v4/cache.go deleted file mode 100644 index cbf22f1d..00000000 --- a/aws/signer/internal/v4/cache.go +++ /dev/null @@ -1,115 +0,0 @@ -package v4 - -import ( - "strings" - "sync" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" -) - -func lookupKey(service, region string) string { - var s strings.Builder - s.Grow(len(region) + len(service) + 3) - s.WriteString(region) - s.WriteRune('/') - s.WriteString(service) - return s.String() -} - -type derivedKey struct { - AccessKey string - Date time.Time - Credential []byte -} - -type derivedKeyCache struct { - values map[string]derivedKey - mutex sync.RWMutex -} - -func newDerivedKeyCache() derivedKeyCache { - return derivedKeyCache{ - values: make(map[string]derivedKey), - } -} - -func (s *derivedKeyCache) Get(credentials aws.Credentials, service, region string, signingTime SigningTime) []byte { - key := lookupKey(service, region) - s.mutex.RLock() - if cred, ok := s.get(key, credentials, signingTime.Time); ok { - s.mutex.RUnlock() - return cred - } - s.mutex.RUnlock() - - s.mutex.Lock() - if cred, ok := s.get(key, credentials, signingTime.Time); ok { - s.mutex.Unlock() - return cred - } - cred := deriveKey(credentials.SecretAccessKey, service, region, signingTime) - entry := derivedKey{ - AccessKey: credentials.AccessKeyID, - Date: signingTime.Time, - Credential: cred, - } - s.values[key] = entry - s.mutex.Unlock() - - return cred -} - -func (s *derivedKeyCache) get(key string, credentials aws.Credentials, signingTime time.Time) ([]byte, bool) { - cacheEntry, ok := s.retrieveFromCache(key) - if ok && cacheEntry.AccessKey == credentials.AccessKeyID && isSameDay(signingTime, cacheEntry.Date) { - return cacheEntry.Credential, true - } - return nil, false -} - -func (s *derivedKeyCache) retrieveFromCache(key string) (derivedKey, bool) { - if v, ok := s.values[key]; ok { - return v, true - } - return derivedKey{}, false -} - -// SigningKeyDeriver derives a signing key from a set of credentials -type SigningKeyDeriver struct { - cache derivedKeyCache -} - -// NewSigningKeyDeriver returns a new SigningKeyDeriver -func NewSigningKeyDeriver() *SigningKeyDeriver { - return &SigningKeyDeriver{ - cache: newDerivedKeyCache(), - } -} - -// DeriveKey returns a derived signing key from the given credentials to be used with SigV4 signing. -func (k *SigningKeyDeriver) DeriveKey(credential aws.Credentials, service, region string, signingTime SigningTime) []byte { - return k.cache.Get(credential, service, region, signingTime) -} - -func deriveKey(secret, service, region string, t SigningTime) []byte { - hmacDate := HMACSHA256([]byte("AWS4"+secret), []byte(t.ShortTimeFormat())) - hmacRegion := HMACSHA256(hmacDate, []byte(region)) - hmacService := HMACSHA256(hmacRegion, []byte(service)) - return HMACSHA256(hmacService, []byte("aws4_request")) -} - -func isSameDay(x, y time.Time) bool { - xYear, xMonth, xDay := x.Date() - yYear, yMonth, yDay := y.Date() - - if xYear != yYear { - return false - } - - if xMonth != yMonth { - return false - } - - return xDay == yDay -} diff --git a/aws/signer/internal/v4/const.go b/aws/signer/internal/v4/const.go deleted file mode 100644 index a23cb003..00000000 --- a/aws/signer/internal/v4/const.go +++ /dev/null @@ -1,40 +0,0 @@ -package v4 - -// Signature Version 4 (SigV4) Constants -const ( - // EmptyStringSHA256 is the hex encoded sha256 value of an empty string - EmptyStringSHA256 = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` - - // UnsignedPayload indicates that the request payload body is unsigned - UnsignedPayload = "UNSIGNED-PAYLOAD" - - // AmzAlgorithmKey indicates the signing algorithm - AmzAlgorithmKey = "X-Amz-Algorithm" - - // AmzSecurityTokenKey indicates the security token to be used with temporary credentials - AmzSecurityTokenKey = "X-Amz-Security-Token" - - // AmzDateKey is the UTC timestamp for the request in the format YYYYMMDD'T'HHMMSS'Z' - AmzDateKey = "X-Amz-Date" - - // AmzCredentialKey is the access key ID and credential scope - AmzCredentialKey = "X-Amz-Credential" - - // AmzSignedHeadersKey is the set of headers signed for the request - AmzSignedHeadersKey = "X-Amz-SignedHeaders" - - // AmzSignatureKey is the query parameter to store the SigV4 signature - AmzSignatureKey = "X-Amz-Signature" - - // TimeFormat is the time format to be used in the X-Amz-Date header or query parameter - TimeFormat = "20060102T150405Z" - - // ShortTimeFormat is the shorten time format used in the credential scope - ShortTimeFormat = "20060102" - - // ContentSHAKey is the SHA256 of request body - ContentSHAKey = "X-Amz-Content-Sha256" - - // StreamingEventsPayload indicates that the request payload body is a signed event stream. - StreamingEventsPayload = "STREAMING-AWS4-HMAC-SHA256-EVENTS" -) diff --git a/aws/signer/internal/v4/header_rules.go b/aws/signer/internal/v4/header_rules.go deleted file mode 100644 index ea08c4e1..00000000 --- a/aws/signer/internal/v4/header_rules.go +++ /dev/null @@ -1,92 +0,0 @@ -package v4 - -import ( - "strings" -) - -// Rules houses a set of Rule needed for validation of a -// string value -type Rules []Rule - -// Rule interface allows for more flexible rules and just simply -// checks whether or not a value adheres to that Rule -type Rule interface { - IsValid(value string) bool -} - -// IsValid will iterate through all rules and see if any rules -// apply to the value and supports nested rules -func (r Rules) IsValid(value string) bool { - for _, rule := range r { - if rule.IsValid(value) { - return true - } - } - return false -} - -// MapRule generic Rule for maps -type MapRule map[string]struct{} - -// IsValid for the map Rule satisfies whether it exists in the map -func (m MapRule) IsValid(value string) bool { - for key := range m { - if strings.EqualFold(key, value) { - return true - } - } - return false -} - -// AllowList is a generic Rule for include listing -type AllowList struct { - Rule -} - -// IsValid for AllowList checks if the value is within the AllowList -func (w AllowList) IsValid(value string) bool { - return w.Rule.IsValid(value) -} - -// ExcludeList is a generic Rule for exclude listing -type ExcludeList struct { - Rule -} - -// IsValid for AllowList checks if the value is within the AllowList -func (b ExcludeList) IsValid(value string) bool { - return !b.Rule.IsValid(value) -} - -// Patterns is a list of strings to match against -type Patterns []string - -// IsValid for Patterns checks each pattern and returns if a match has -// been found -func (p Patterns) IsValid(value string) bool { - for _, pattern := range p { - if hasPrefixFold(value, pattern) { - return true - } - } - return false -} - -// InclusiveRules rules allow for rules to depend on one another -type InclusiveRules []Rule - -// IsValid will return true if all rules are true -func (r InclusiveRules) IsValid(value string) bool { - for _, rule := range r { - if !rule.IsValid(value) { - return false - } - } - return true -} - -// hasPrefixFold tests whether the string s begins with prefix, interpreted as UTF-8 strings, -// under Unicode case-folding. -func hasPrefixFold(s, prefix string) bool { - return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) -} diff --git a/aws/signer/internal/v4/headers.go b/aws/signer/internal/v4/headers.go deleted file mode 100644 index cbd22107..00000000 --- a/aws/signer/internal/v4/headers.go +++ /dev/null @@ -1,32 +0,0 @@ -package v4 - -// IgnoredHeaders is a list of headers that are ignored during signing -var IgnoredHeaders = Rules{ - ExcludeList{ - MapRule{ - "Authorization": struct{}{}, - "User-Agent": struct{}{}, - "X-Amzn-Trace-Id": struct{}{}, - "Expect": struct{}{}, - "Transfer-Encoding": struct{}{}, - }, - }, -} - -// RequiredSignedHeaders are request headers that must be part of SignedHeaders -// whenever they are present on the request. -var RequiredSignedHeaders = Rules{ - AllowList{ - MapRule{ - "Host": struct{}{}, - }, - }, - Patterns{"X-Amz-"}, -} - -// AllowedQueryHoisting is a allowed list for Build query headers. The boolean value -// represents whether or not it is a pattern. -var AllowedQueryHoisting = InclusiveRules{ - ExcludeList{RequiredSignedHeaders}, - Patterns{"X-Amz-"}, -} diff --git a/aws/signer/internal/v4/headers_test.go b/aws/signer/internal/v4/headers_test.go deleted file mode 100644 index 4484c294..00000000 --- a/aws/signer/internal/v4/headers_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package v4 - -import "testing" - -func TestAllowedQueryHoisting(t *testing.T) { - cases := map[string]struct { - Header string - ExpectHoist bool - }{ - "object-lock": { - Header: "X-Amz-Object-Lock-Mode", - ExpectHoist: false, - }, - "s3 metadata": { - Header: "X-Amz-Meta-SomeName", - ExpectHoist: false, - }, - "another header": { - Header: "X-Amz-SomeOtherHeader", - ExpectHoist: false, - }, - "lowercase amz header": { - Header: "x-amz-someotherheader", - ExpectHoist: false, - }, - "mixed case amz header": { - Header: "x-AmZ-someotherheader", - ExpectHoist: false, - }, - "non-amz content header": { - Header: "Content-Type", - ExpectHoist: false, - }, - "non X-AMZ header": { - Header: "X-SomeOtherHeader", - ExpectHoist: false, - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - if e, a := c.ExpectHoist, AllowedQueryHoisting.IsValid(c.Header); e != a { - t.Errorf("expect hoist %v, was %v", e, a) - } - }) - } -} - -func TestRequiredSignedHeaders(t *testing.T) { - cases := map[string]struct { - Header string - ExpectRequired bool - }{ - "known content header": { - Header: "Content-Type", - ExpectRequired: false, - }, - "known content header lowercase": { - Header: "content-type", - ExpectRequired: false, - }, - "known conditional header": { - Header: "If-Match", - ExpectRequired: false, - }, - "range header": { - Header: "Range", - ExpectRequired: false, - }, - "content md5 header": { - Header: "Content-Md5", - ExpectRequired: false, - }, - "arbitrary amz header": { - Header: "X-Amz-SomeOtherHeader", - ExpectRequired: true, - }, - "arbitrary amz header lowercase": { - Header: "x-amz-someotherheader", - ExpectRequired: true, - }, - "object-lock amz header": { - Header: "X-Amz-Object-Lock-Mode", - ExpectRequired: true, - }, - "metadata amz header": { - Header: "X-Amz-Meta-SomeName", - ExpectRequired: true, - }, - "non-amz custom header": { - Header: "X-SomeOtherHeader", - ExpectRequired: false, - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - if e, a := c.ExpectRequired, RequiredSignedHeaders.IsValid(c.Header); e != a { - t.Errorf("expect required %v, was %v", e, a) - } - }) - } -} - -func TestIgnoredHeaders(t *testing.T) { - cases := map[string]struct { - Header string - ExpectIgnored bool - }{ - "expect": { - Header: "Expect", - ExpectIgnored: true, - }, - "user-agent": { - Header: "User-Agent", - ExpectIgnored: true, - }, - "transfer-encoding": { - Header: "Transfer-Encoding", - ExpectIgnored: true, - }, - "authorization": { - Header: "Authorization", - ExpectIgnored: true, - }, - "authorization lowercase": { - Header: "authorization", - ExpectIgnored: true, - }, - "trace id lowercase": { - Header: "x-amzn-trace-id", - ExpectIgnored: true, - }, - "X-AMZ header": { - Header: "X-Amz-Content-Sha256", - ExpectIgnored: false, - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - if e, a := c.ExpectIgnored, IgnoredHeaders.IsValid(c.Header); e == a { - t.Errorf("expect ignored %v, was %v", e, a) - } - }) - } -} diff --git a/aws/signer/internal/v4/hmac.go b/aws/signer/internal/v4/hmac.go deleted file mode 100644 index e7fa7a1b..00000000 --- a/aws/signer/internal/v4/hmac.go +++ /dev/null @@ -1,13 +0,0 @@ -package v4 - -import ( - "crypto/hmac" - "crypto/sha256" -) - -// HMACSHA256 computes a HMAC-SHA256 of data given the provided key. -func HMACSHA256(key []byte, data []byte) []byte { - hash := hmac.New(sha256.New, key) - hash.Write(data) - return hash.Sum(nil) -} diff --git a/aws/signer/internal/v4/host.go b/aws/signer/internal/v4/host.go deleted file mode 100644 index 0c5a3e87..00000000 --- a/aws/signer/internal/v4/host.go +++ /dev/null @@ -1,75 +0,0 @@ -package v4 - -import ( - "net/http" - "strings" -) - -// SanitizeHostForHeader removes default port from host and updates request.Host -func SanitizeHostForHeader(r *http.Request) { - host := getHost(r) - port := portOnly(host) - if port != "" && isDefaultPort(r.URL.Scheme, port) { - r.Host = stripPort(host) - } -} - -// Returns host from request -func getHost(r *http.Request) string { - if r.Host != "" { - return r.Host - } - - return r.URL.Host -} - -// Hostname returns u.Host, without any port number. -// -// If Host is an IPv6 literal with a port number, Hostname returns the -// IPv6 literal without the square brackets. IPv6 literals may include -// a zone identifier. -// -// Copied from the Go 1.8 standard library (net/url) -func stripPort(hostport string) string { - before, _, ok := strings.Cut(hostport, ":") - if !ok { - return hostport - } - if before, _, ok := strings.Cut(hostport, "]"); ok { - return strings.TrimPrefix(before, "[") - } - return before -} - -// Port returns the port part of u.Host, without the leading colon. -// If u.Host doesn't contain a port, Port returns an empty string. -// -// Copied from the Go 1.8 standard library (net/url) -func portOnly(hostport string) string { - _, after, ok := strings.Cut(hostport, ":") - if !ok { - return "" - } - if _, after, ok := strings.Cut(hostport, "]:"); ok { - return after - } - if strings.Contains(hostport, "]") { - return "" - } - return after -} - -// Returns true if the specified URI is using the standard port -// (i.e. port 80 for HTTP URIs or 443 for HTTPS URIs) -func isDefaultPort(scheme, port string) bool { - if port == "" { - return true - } - - lowerCaseScheme := strings.ToLower(scheme) - if (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") { - return true - } - - return false -} diff --git a/aws/signer/internal/v4/scope.go b/aws/signer/internal/v4/scope.go deleted file mode 100644 index fc788790..00000000 --- a/aws/signer/internal/v4/scope.go +++ /dev/null @@ -1,13 +0,0 @@ -package v4 - -import "strings" - -// BuildCredentialScope builds the Signature Version 4 (SigV4) signing scope -func BuildCredentialScope(signingTime SigningTime, region, service string) string { - return strings.Join([]string{ - signingTime.ShortTimeFormat(), - region, - service, - "aws4_request", - }, "/") -} diff --git a/aws/signer/internal/v4/time.go b/aws/signer/internal/v4/time.go deleted file mode 100644 index 1de06a76..00000000 --- a/aws/signer/internal/v4/time.go +++ /dev/null @@ -1,36 +0,0 @@ -package v4 - -import "time" - -// SigningTime provides a wrapper around a time.Time which provides cached values for SigV4 signing. -type SigningTime struct { - time.Time - timeFormat string - shortTimeFormat string -} - -// NewSigningTime creates a new SigningTime given a time.Time -func NewSigningTime(t time.Time) SigningTime { - return SigningTime{ - Time: t, - } -} - -// TimeFormat provides a time formatted in the X-Amz-Date format. -func (m *SigningTime) TimeFormat() string { - return m.format(&m.timeFormat, TimeFormat) -} - -// ShortTimeFormat provides a time formatted of 20060102. -func (m *SigningTime) ShortTimeFormat() string { - return m.format(&m.shortTimeFormat, ShortTimeFormat) -} - -func (m *SigningTime) format(target *string, format string) string { - if len(*target) > 0 { - return *target - } - v := m.Time.Format(format) - *target = v - return v -} diff --git a/aws/signer/internal/v4/util.go b/aws/signer/internal/v4/util.go deleted file mode 100644 index d025dbaa..00000000 --- a/aws/signer/internal/v4/util.go +++ /dev/null @@ -1,80 +0,0 @@ -package v4 - -import ( - "net/url" - "strings" -) - -const doubleSpace = " " - -// StripExcessSpaces will rewrite the passed in slice's string values to not -// contain multiple side-by-side spaces. -func StripExcessSpaces(str string) string { - var j, k, l, m, spaces int - // Trim trailing spaces - for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { - } - - // Trim leading spaces - for k = 0; k < j && str[k] == ' '; k++ { - } - str = str[k : j+1] - - // Strip multiple spaces. - j = strings.Index(str, doubleSpace) - if j < 0 { - return str - } - - buf := []byte(str) - for k, m, l = j, j, len(buf); k < l; k++ { - if buf[k] == ' ' { - if spaces == 0 { - // First space. - buf[m] = buf[k] - m++ - } - spaces++ - } else { - // End of multiple spaces. - spaces = 0 - buf[m] = buf[k] - m++ - } - } - - return string(buf[:m]) -} - -// GetURIPath returns the escaped URI component from the provided URL. -func GetURIPath(u *url.URL) string { - var uriPath string - - if len(u.Opaque) > 0 { - const schemeSep, pathSep, queryStart = "//", "/", "?" - - opaque := u.Opaque - // Cut off the query string if present. - if idx := strings.Index(opaque, queryStart); idx >= 0 { - opaque = opaque[:idx] - } - - // Cutout the scheme separator if present. - if strings.Index(opaque, schemeSep) == 0 { - opaque = opaque[len(schemeSep):] - } - - // capture URI path starting with first path separator. - if idx := strings.Index(opaque, pathSep); idx >= 0 { - uriPath = opaque[idx:] - } - } else { - uriPath = u.EscapedPath() - } - - if len(uriPath) == 0 { - uriPath = "/" - } - - return uriPath -} diff --git a/aws/signer/internal/v4/util_test.go b/aws/signer/internal/v4/util_test.go deleted file mode 100644 index 277f87b6..00000000 --- a/aws/signer/internal/v4/util_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package v4 - -import ( - "net/http" - "net/url" - "testing" -) - -func lazyURLParse(v string) func() (*url.URL, error) { - return func() (*url.URL, error) { - return url.Parse(v) - } -} - -func TestGetURIPath(t *testing.T) { - cases := map[string]struct { - getURL func() (*url.URL, error) - expect string - }{ - // Cases - "with scheme": { - getURL: lazyURLParse("https://localhost:9000"), - expect: "/", - }, - "no port, with scheme": { - getURL: lazyURLParse("https://localhost"), - expect: "/", - }, - "without scheme": { - getURL: lazyURLParse("localhost:9000"), - expect: "/", - }, - "without scheme, with path": { - getURL: lazyURLParse("localhost:9000/abc123"), - expect: "/abc123", - }, - "without scheme, with separator": { - getURL: lazyURLParse("//localhost:9000"), - expect: "/", - }, - "no port, without scheme, with separator": { - getURL: lazyURLParse("//localhost"), - expect: "/", - }, - "without scheme, with separator, with path": { - getURL: lazyURLParse("//localhost:9000/abc123"), - expect: "/abc123", - }, - "no port, without scheme, with separator, with path": { - getURL: lazyURLParse("//localhost/abc123"), - expect: "/abc123", - }, - "opaque with query string": { - getURL: lazyURLParse("localhost:9000/abc123?efg=456"), - expect: "/abc123", - }, - "failing test": { - getURL: func() (*url.URL, error) { - endpoint := "https://service.region.amazonaws.com" - req, _ := http.NewRequest("POST", endpoint, nil) - u := req.URL - - u.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" - - query := u.Query() - query.Set("some-query-key", "value") - u.RawQuery = query.Encode() - - return u, nil - }, - expect: "/bucket/key-._~,!@#$%^&*()", - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - u, err := c.getURL() - if err != nil { - t.Fatalf("failed to get URL, %v", err) - } - - actual := GetURIPath(u) - if e, a := c.expect, actual; e != a { - t.Errorf("expect %v path, got %v", e, a) - } - }) - } -} - -func TestStripExcessHeaders(t *testing.T) { - vals := []string{ - "", - "123", - "1 2 3", - "1 2 3 ", - " 1 2 3", - "1 2 3", - "1 23", - "1 2 3", - "1 2 ", - " 1 2 ", - "12 3", - "12 3 1", - "12 3 1", - "12 3 1abc123", - } - - expected := []string{ - "", - "123", - "1 2 3", - "1 2 3", - "1 2 3", - "1 2 3", - "1 23", - "1 2 3", - "1 2", - "1 2", - "12 3", - "12 3 1", - "12 3 1", - "12 3 1abc123", - } - - for i := range vals { - r := StripExcessSpaces(vals[i]) - if e, a := expected[i], r; e != a { - t.Errorf("%d, expect %v, got %v", i, e, a) - } - } -} - -var stripExcessSpaceCases = []string{ - `AWS4-HMAC-SHA256 Credential=AKIDFAKEIDFAKEID/20160628/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=1234567890abcdef1234567890abcdef1234567890abcdef`, - `123 321 123 321`, - ` 123 321 123 321 `, - ` 123 321 123 321 `, - "123", - "1 2 3", - " 1 2 3", - "1 2 3", - "1 23", - "1 2 3", - "1 2 ", - " 1 2 ", - "12 3", - "12 3 1", - "12 3 1", - "12 3 1abc123", -} - -func BenchmarkStripExcessSpaces(b *testing.B) { - for i := 0; i < b.N; i++ { - for _, v := range stripExcessSpaceCases { - StripExcessSpaces(v) - } - } -} diff --git a/aws/signer/v4/functional_test.go b/aws/signer/v4/functional_test.go deleted file mode 100644 index a7d4f738..00000000 --- a/aws/signer/v4/functional_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package v4_test - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" - "github.com/versity/versitygw/aws/internal/awstesting/unit" - v4Internal "github.com/versity/versitygw/aws/signer/internal/v4" -) - -var standaloneSignCases = []struct { - OrigURI string - OrigQuery string - Region, Service, SubDomain string - ExpSig string - EscapedURI string -}{ - { - OrigURI: `/logs-*/_search`, - OrigQuery: `pretty=true`, - Region: "us-west-2", Service: "es", SubDomain: "hostname-clusterkey", - EscapedURI: `/logs-%2A/_search`, - ExpSig: `AWS4-HMAC-SHA256 Credential=AKID/19700101/us-west-2/es/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=79d0760751907af16f64a537c1242416dacf51204a7dd5284492d15577973b91`, - }, -} - -func TestStandaloneSign_CustomURIEscape(t *testing.T) { - var expectSig = `AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/es/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=6601e883cc6d23871fd6c2a394c5677ea2b8c82b04a6446786d64cd74f520967` - - creds, err := unit.Config().Credentials.Retrieve(context.Background()) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - signer := v4.NewSigner(func(signer *v4.SignerOptions) { - signer.DisableURIPathEscaping = true - }) - - host := "https://subdomain.us-east-1.es.amazonaws.com" - req, err := http.NewRequest("GET", host, nil) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - - req.URL.Path = `/log-*/_search` - req.URL.Opaque = "//subdomain.us-east-1.es.amazonaws.com/log-%2A/_search" - - err = signer.SignHTTP(context.Background(), creds, req, v4Internal.EmptyStringSHA256, "es", "us-east-1", time.Unix(0, 0)) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - - actual := req.Header.Get("Authorization") - if e, a := expectSig, actual; e != a { - t.Errorf("expect %v, got %v", e, a) - } -} - -func TestStandaloneSign(t *testing.T) { - creds, err := unit.Config().Credentials.Retrieve(context.Background()) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - signer := v4.NewSigner() - - for _, c := range standaloneSignCases { - host := fmt.Sprintf("https://%s.%s.%s.amazonaws.com", - c.SubDomain, c.Region, c.Service) - - req, err := http.NewRequest("GET", host, nil) - if err != nil { - t.Errorf("expected no error, but received %v", err) - } - - // URL.EscapedPath() will be used by the signer to get the - // escaped form of the request's URI path. - req.URL.Path = c.OrigURI - req.URL.RawQuery = c.OrigQuery - - err = signer.SignHTTP(context.Background(), creds, req, v4Internal.EmptyStringSHA256, c.Service, c.Region, time.Unix(0, 0)) - if err != nil { - t.Errorf("expected no error, but received %v", err) - } - - actual := req.Header.Get("Authorization") - if e, a := c.ExpSig, actual; e != a { - t.Errorf("expected %v, but received %v", e, a) - } - if e, a := c.OrigURI, req.URL.Path; e != a { - t.Errorf("expected %v, but received %v", e, a) - } - if e, a := c.EscapedURI, req.URL.EscapedPath(); e != a { - t.Errorf("expected %v, but received %v", e, a) - } - } -} - -func TestStandaloneSign_RawPath(t *testing.T) { - creds, err := unit.Config().Credentials.Retrieve(context.Background()) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - signer := v4.NewSigner() - - for _, c := range standaloneSignCases { - host := fmt.Sprintf("https://%s.%s.%s.amazonaws.com", - c.SubDomain, c.Region, c.Service) - - req, err := http.NewRequest("GET", host, nil) - if err != nil { - t.Errorf("expected no error, but received %v", err) - } - - // URL.EscapedPath() will be used by the signer to get the - // escaped form of the request's URI path. - req.URL.Path = c.OrigURI - req.URL.RawPath = c.EscapedURI - req.URL.RawQuery = c.OrigQuery - - err = signer.SignHTTP(context.Background(), creds, req, v4Internal.EmptyStringSHA256, c.Service, c.Region, time.Unix(0, 0)) - if err != nil { - t.Errorf("expected no error, but received %v", err) - } - - actual := req.Header.Get("Authorization") - if e, a := c.ExpSig, actual; e != a { - t.Errorf("expected %v, but received %v", e, a) - } - if e, a := c.OrigURI, req.URL.Path; e != a { - t.Errorf("expected %v, but received %v", e, a) - } - if e, a := c.EscapedURI, req.URL.EscapedPath(); e != a { - t.Errorf("expected %v, but received %v", e, a) - } - } -} diff --git a/aws/signer/v4/header_rules.go b/aws/signer/v4/header_rules.go deleted file mode 100644 index dec685b5..00000000 --- a/aws/signer/v4/header_rules.go +++ /dev/null @@ -1,14 +0,0 @@ -package v4 - -import v4Internal "github.com/versity/versitygw/aws/signer/internal/v4" - -// IsRequiredSignedHeader reports whether a header must be signed when it is -// present on an incoming request. -func IsRequiredSignedHeader(header string) bool { - return v4Internal.RequiredSignedHeaders.IsValid(header) -} - -// IsIgnoredHeader reports whether a header is normally excluded from signing. -func IsIgnoredHeader(header string) bool { - return !v4Internal.IgnoredHeaders.IsValid(header) -} diff --git a/aws/signer/v4/v4.go b/aws/signer/v4/v4.go deleted file mode 100644 index 03beebae..00000000 --- a/aws/signer/v4/v4.go +++ /dev/null @@ -1,588 +0,0 @@ -// Package v4 implements signing for AWS V4 signer -// -// Provides request signing for request that need to be signed with -// AWS V4 Signatures. -// -// # Standalone Signer -// -// Generally using the signer outside of the SDK should not require any additional -// -// The signer does this by taking advantage of the URL.EscapedPath method. If your request URI requires -// -// additional escaping you many need to use the URL.Opaque to define what the raw URI should be sent -// to the service as. -// -// The signer will first check the URL.Opaque field, and use its value if set. -// The signer does require the URL.Opaque field to be set in the form of: -// -// "///" -// -// // e.g. -// "//example.com/some/path" -// -// The leading "//" and hostname are required or the URL.Opaque escaping will -// not work correctly. -// -// If URL.Opaque is not set the signer will fallback to the URL.EscapedPath() -// method and using the returned value. -// -// AWS v4 signature validation requires that the canonical string's URI path -// element must be the URI escaped form of the HTTP request's path. -// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html -// -// The Go HTTP client will perform escaping automatically on the request. Some -// of these escaping may cause signature validation errors because the HTTP -// request differs from the URI path or query that the signature was generated. -// https://golang.org/pkg/net/url/#URL.EscapedPath -// -// Because of this, it is recommended that when using the signer outside of the -// SDK that explicitly escaping the request prior to being signed is preferable, -// and will help prevent signature validation errors. This can be done by setting -// the URL.Opaque or URL.RawPath. The SDK will use URL.Opaque first and then -// call URL.EscapedPath() if Opaque is not set. -// -// Test `TestStandaloneSign` provides a complete example of using the signer -// outside of the SDK and pre-escaping the URI path. -package v4 - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "hash" - "net/http" - "net/textproto" - "net/url" - "slices" - "sort" - "strconv" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/smithy-go/encoding/httpbinding" - "github.com/aws/smithy-go/logging" - v4Internal "github.com/versity/versitygw/aws/signer/internal/v4" -) - -const ( - signingAlgorithm = "AWS4-HMAC-SHA256" - authorizationHeader = "Authorization" - - // Version of signing v4 - Version = "SigV4" -) - -// HTTPSigner is an interface to a SigV4 signer that can sign HTTP requests -type HTTPSigner interface { - SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*SignerOptions)) error -} - -type keyDerivator interface { - DeriveKey(credential aws.Credentials, service, region string, signingTime v4Internal.SigningTime) []byte -} - -type SignMetadata struct { - StringToSign string - CanonicalString string -} - -// SignerOptions is the SigV4 Signer options. -type SignerOptions struct { - // Disables the Signer's moving HTTP header key/value pairs from the HTTP - // request header to the request's query string. This is most commonly used - // with pre-signed requests preventing headers from being added to the - // request's query string. - DisableHeaderHoisting bool - - // Disables the automatic escaping of the URI path of the request for the - // siganture's canonical string's path. For services that do not need additional - // escaping then use this to disable the signer escaping the path. - // - // S3 is an example of a service that does not need additional escaping. - // - // http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html - DisableURIPathEscaping bool - - // The logger to send log messages to. - Logger logging.Logger - - // Enable logging of signed requests. - // This will enable logging of the canonical request, the string to sign, and for presigning the subsequent - // presigned URL. - LogSigning bool - - // Disables setting the session token on the request as part of signing - // through X-Amz-Security-Token. This is needed for variations of v4 that - // present the token elsewhere. - DisableSessionToken bool -} - -// Signer applies AWS v4 signing to given request. Use this to sign requests -// that need to be signed with AWS V4 Signatures. -type Signer struct { - options SignerOptions - keyDerivator keyDerivator -} - -// NewSigner returns a new SigV4 Signer -func NewSigner(optFns ...func(signer *SignerOptions)) *Signer { - options := SignerOptions{} - - for _, fn := range optFns { - fn(&options) - } - - return &Signer{options: options, keyDerivator: v4Internal.NewSigningKeyDeriver()} -} - -type httpSigner struct { - Request *http.Request - ServiceName string - Region string - Time v4Internal.SigningTime - Credentials aws.Credentials - KeyDerivator keyDerivator - IsPreSign bool - SignedHdrs []string - - PayloadHash string - - DisableHeaderHoisting bool - DisableURIPathEscaping bool - DisableSessionToken bool -} - -func (s *httpSigner) Build() (signedRequest, error) { - req := s.Request - - query := req.URL.Query() - headers := req.Header - - s.setRequiredSigningFields(headers, query) - - // Sort Each Query Key's Values - for key := range query { - sort.Strings(query[key]) - } - - v4Internal.SanitizeHostForHeader(req) - - credentialScope := s.buildCredentialScope() - credentialStr := s.Credentials.AccessKeyID + "/" + credentialScope - if s.IsPreSign { - query.Set(v4Internal.AmzCredentialKey, credentialStr) - } - - unsignedHeaders := headers - if s.IsPreSign && !s.DisableHeaderHoisting { - var urlValues url.Values - urlValues, unsignedHeaders = buildQuery(v4Internal.AllowedQueryHoisting, headers) - for k := range urlValues { - query[k] = urlValues[k] - } - } - - host := req.URL.Host - if len(req.Host) > 0 { - host = req.Host - } - - signedHeaders, signedHeadersStr, canonicalHeaderStr := s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength) - - if s.IsPreSign { - query.Set(v4Internal.AmzSignedHeadersKey, signedHeadersStr) - } - - var rawQuery strings.Builder - rawQuery.WriteString(strings.Replace(query.Encode(), "+", "%20", -1)) - - canonicalURI := v4Internal.GetURIPath(req.URL) - if !s.DisableURIPathEscaping { - canonicalURI = httpbinding.EscapePath(canonicalURI, false) - } - - canonicalString := s.buildCanonicalString( - req.Method, - canonicalURI, - rawQuery.String(), - signedHeadersStr, - canonicalHeaderStr, - ) - - strToSign := s.buildStringToSign(credentialScope, canonicalString) - signingSignature, err := s.buildSignature(strToSign) - if err != nil { - return signedRequest{}, err - } - - if s.IsPreSign { - rawQuery.WriteString("&X-Amz-Signature=") - rawQuery.WriteString(signingSignature) - } else { - headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature)) - } - - req.URL.RawQuery = rawQuery.String() - - return signedRequest{ - Request: req, - SignedHeaders: signedHeaders, - CanonicalString: canonicalString, - StringToSign: strToSign, - PreSigned: s.IsPreSign, - }, nil -} - -func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string { - const credential = "Credential=" - const signedHeaders = "SignedHeaders=" - const signature = "Signature=" - const commaSpace = ", " - - var parts strings.Builder - parts.Grow(len(signingAlgorithm) + 1 + - len(credential) + len(credentialStr) + 2 + - len(signedHeaders) + len(signedHeadersStr) + 2 + - len(signature) + len(signingSignature), - ) - parts.WriteString(signingAlgorithm) - parts.WriteRune(' ') - parts.WriteString(credential) - parts.WriteString(credentialStr) - parts.WriteString(commaSpace) - parts.WriteString(signedHeaders) - parts.WriteString(signedHeadersStr) - parts.WriteString(commaSpace) - parts.WriteString(signature) - parts.WriteString(signingSignature) - return parts.String() -} - -// SignHTTP signs AWS v4 requests with the provided payload hash, service name, region the -// request is made to, and time the request is signed at. The signTime allows -// you to specify that a request is signed for the future, and cannot be -// used until then. -// -// The payloadHash is the hex encoded SHA-256 hash of the request payload, and -// must be provided. Even if the request has no payload (aka body). If the -// request has no payload you should use the hex encoded SHA-256 of an empty -// string as the payloadHash value. -// -// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -// -// Some services such as Amazon S3 accept alternative values for the payload -// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be -// included in the request signature. -// -// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html -// -// Sign differs from Presign in that it will sign the request using HTTP -// header values. This type of signing is intended for http.Request values that -// will not be shared, or are shared in a way the header values on the request -// will not be lost. -// -// The passed in request will be modified in place. -func (s Signer) SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, signedHdrs []string, optFns ...func(options *SignerOptions)) (*SignMetadata, error) { - options := s.options - - for _, fn := range optFns { - fn(&options) - } - - signer := &httpSigner{ - Request: r, - PayloadHash: payloadHash, - ServiceName: service, - Region: region, - Credentials: credentials, - Time: v4Internal.NewSigningTime(signingTime.UTC()), - DisableHeaderHoisting: options.DisableHeaderHoisting, - DisableURIPathEscaping: options.DisableURIPathEscaping, - DisableSessionToken: options.DisableSessionToken, - KeyDerivator: s.keyDerivator, - SignedHdrs: signedHdrs, - } - - signedRequest, err := signer.Build() - if err != nil { - return nil, err - } - - logSigningInfo(ctx, options, &signedRequest, false) - - return &SignMetadata{ - StringToSign: signedRequest.StringToSign, - CanonicalString: signedRequest.CanonicalString, - }, nil -} - -// PresignHTTP signs AWS v4 requests with the payload hash, service name, region -// the request is made to, and time the request is signed at. The signTime -// allows you to specify that a request is signed for the future, and cannot -// be used until then. -// -// Returns the signed URL and the map of HTTP headers that were included in the -// signature or an error if signing the request failed. For presigned requests -// these headers and their values must be included on the HTTP request when it -// is made. This is helpful to know what header values need to be shared with -// the party the presigned request will be distributed to. -// -// The payloadHash is the hex encoded SHA-256 hash of the request payload, and -// must be provided. Even if the request has no payload (aka body). If the -// request has no payload you should use the hex encoded SHA-256 of an empty -// string as the payloadHash value. -// -// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -// -// Some services such as Amazon S3 accept alternative values for the payload -// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be -// included in the request signature. -// -// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html -// -// PresignHTTP differs from SignHTTP in that it will sign the request using -// query string instead of header values. This allows you to share the -// Presigned Request's URL with third parties, or distribute it throughout your -// system with minimal dependencies. -// -// PresignHTTP will not set the expires time of the presigned request -// automatically. To specify the expire duration for a request add the -// "X-Amz-Expires" query parameter on the request with the value as the -// duration in seconds the presigned URL should be considered valid for. This -// parameter is not used by all AWS services, and is most notable used by -// Amazon S3 APIs. -// -// expires := 20 * time.Minute -// query := req.URL.Query() -// query.Set("X-Amz-Expires", strconv.FormatInt(int64(expires/time.Second), 10)) -// req.URL.RawQuery = query.Encode() -// -// This method does not modify the provided request. -func (s *Signer) PresignHTTP( - ctx context.Context, credentials aws.Credentials, r *http.Request, - payloadHash string, service string, region string, signingTime time.Time, - signedHdrs []string, - optFns ...func(*SignerOptions), -) (string, http.Header, *SignMetadata, error) { - options := s.options - - for _, fn := range optFns { - fn(&options) - } - - signer := &httpSigner{ - Request: r.Clone(r.Context()), - PayloadHash: payloadHash, - ServiceName: service, - Region: region, - Credentials: credentials, - Time: v4Internal.NewSigningTime(signingTime.UTC()), - IsPreSign: true, - DisableHeaderHoisting: options.DisableHeaderHoisting, - DisableURIPathEscaping: options.DisableURIPathEscaping, - DisableSessionToken: options.DisableSessionToken, - KeyDerivator: s.keyDerivator, - SignedHdrs: signedHdrs, - } - - signedRequest, err := signer.Build() - if err != nil { - return "", nil, nil, err - } - - logSigningInfo(ctx, options, &signedRequest, true) - - signedHeaders := make(http.Header) - - // For the signed headers we canonicalize the header keys in the returned map. - // This avoids situations where can standard library double headers like host header. For example the standard - // library will set the Host header, even if it is present in lower-case form. - for k, v := range signedRequest.SignedHeaders { - key := textproto.CanonicalMIMEHeaderKey(k) - signedHeaders[key] = append(signedHeaders[key], v...) - } - - return signedRequest.Request.URL.String(), signedHeaders, &SignMetadata{ - StringToSign: signedRequest.StringToSign, - CanonicalString: signedRequest.CanonicalString, - }, nil -} - -func (s *httpSigner) buildCredentialScope() string { - return v4Internal.BuildCredentialScope(s.Time, s.Region, s.ServiceName) -} - -func buildQuery(r v4Internal.Rule, header http.Header) (url.Values, http.Header) { - query := url.Values{} - unsignedHeaders := http.Header{} - for k, h := range header { - if r.IsValid(k) { - query[k] = h - } else { - unsignedHeaders[k] = h - } - } - - return query, unsignedHeaders -} - -func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { - signed = make(http.Header) - - var headers []string - const hostHeader = "host" - headers = append(headers, hostHeader) - signed[hostHeader] = append(signed[hostHeader], host) - - const contentLengthHeader = "content-length" - if slices.Contains(s.SignedHdrs, contentLengthHeader) { - headers = append(headers, contentLengthHeader) - signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) - } - - for k, v := range header { - if !s.shouldSignHeader(k, rule) { - continue // ignored header - } - if strings.EqualFold(k, contentLengthHeader) { - // prevent signing already handled content-length header. - continue - } - - lowerCaseKey := strings.ToLower(k) - if _, ok := signed[lowerCaseKey]; ok { - // include additional values - signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) - continue - } - - headers = append(headers, lowerCaseKey) - signed[lowerCaseKey] = v - } - sort.Strings(headers) - - signedHeaders = strings.Join(headers, ";") - - var canonicalHeaders strings.Builder - n := len(headers) - const colon = ':' - for i := range n { - if headers[i] == hostHeader { - canonicalHeaders.WriteString(hostHeader) - canonicalHeaders.WriteRune(colon) - canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host)) - } else { - canonicalHeaders.WriteString(headers[i]) - canonicalHeaders.WriteRune(colon) - // Trim out leading, trailing, and dedup inner spaces from signed header values. - values := signed[headers[i]] - for j, v := range values { - cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v)) - canonicalHeaders.WriteString(cleanedValue) - if j < len(values)-1 { - canonicalHeaders.WriteRune(',') - } - } - } - canonicalHeaders.WriteRune('\n') - } - canonicalHeadersStr = canonicalHeaders.String() - - return signed, signedHeaders, canonicalHeadersStr -} - -func (s *httpSigner) shouldSignHeader(header string, rule v4Internal.Rule) bool { - if strings.EqualFold(header, authorizationHeader) { - return false - } - 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 { - return strings.Join([]string{ - method, - uri, - query, - canonicalHeaders, - signedHeaders, - s.PayloadHash, - }, "\n") -} - -func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string { - return strings.Join([]string{ - signingAlgorithm, - s.Time.TimeFormat(), - credentialScope, - hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), - }, "\n") -} - -func makeHash(hash hash.Hash, b []byte) []byte { - hash.Reset() - hash.Write(b) - return hash.Sum(nil) -} - -func (s *httpSigner) buildSignature(strToSign string) (string, error) { - key := s.KeyDerivator.DeriveKey(s.Credentials, s.ServiceName, s.Region, s.Time) - return hex.EncodeToString(v4Internal.HMACSHA256(key, []byte(strToSign))), nil -} - -func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) { - amzDate := s.Time.TimeFormat() - - if s.IsPreSign { - query.Set(v4Internal.AmzAlgorithmKey, signingAlgorithm) - sessionToken := s.Credentials.SessionToken - if !s.DisableSessionToken && len(sessionToken) > 0 { - query.Set("X-Amz-Security-Token", sessionToken) - } - - query.Set(v4Internal.AmzDateKey, amzDate) - return - } - - headers[v4Internal.AmzDateKey] = append(headers[v4Internal.AmzDateKey][:0], amzDate) - - if !s.DisableSessionToken && len(s.Credentials.SessionToken) > 0 { - headers[v4Internal.AmzSecurityTokenKey] = append(headers[v4Internal.AmzSecurityTokenKey][:0], s.Credentials.SessionToken) - } -} - -func logSigningInfo(ctx context.Context, options SignerOptions, request *signedRequest, isPresign bool) { - if !options.LogSigning { - return - } - signedURLMsg := "" - if isPresign { - signedURLMsg = fmt.Sprintf(logSignedURLMsg, request.Request.URL.String()) - } - logger := logging.WithContext(ctx, options.Logger) - logger.Logf(logging.Debug, logSignInfoMsg, request.CanonicalString, request.StringToSign, signedURLMsg) -} - -type signedRequest struct { - Request *http.Request - SignedHeaders http.Header - CanonicalString string - StringToSign string - PreSigned bool -} - -const logSignInfoMsg = `Request Signature: ----[ CANONICAL STRING ]----------------------------- -%s ----[ STRING TO SIGN ]-------------------------------- -%s%s ------------------------------------------------------` -const logSignedURLMsg = ` ----[ SIGNED URL ]------------------------------------ -%s` diff --git a/aws/signer/v4/v4_test.go b/aws/signer/v4/v4_test.go deleted file mode 100644 index 1fe0bb20..00000000 --- a/aws/signer/v4/v4_test.go +++ /dev/null @@ -1,380 +0,0 @@ -package v4 - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/google/go-cmp/cmp" - v4Internal "github.com/versity/versitygw/aws/signer/internal/v4" -) - -var testCredentials = aws.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION"} - -func buildRequest(serviceName, region, body string) (*http.Request, string) { - reader := strings.NewReader(body) - return buildRequestWithBodyReader(serviceName, region, reader) -} - -func buildRequestWithBodyReader(serviceName, region string, body io.Reader) (*http.Request, string) { - var bodyLen int - - type lenner interface { - Len() int - } - if lr, ok := body.(lenner); ok { - bodyLen = lr.Len() - } - - endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" - req, _ := http.NewRequest("POST", endpoint, body) - req.URL.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" - req.Header.Set("X-Amz-Target", "prefix.Operation") - req.Header.Set("Content-Type", "application/x-amz-json-1.0") - - if bodyLen > 0 { - req.ContentLength = int64(bodyLen) - } - - req.Header.Set("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") - req.Header.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") - req.Header.Add("X-amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") - - h := sha256.New() - _, _ = io.Copy(h, body) - payloadHash := hex.EncodeToString(h.Sum(nil)) - - return req, payloadHash -} - -func TestPresignRequest(t *testing.T) { - req, body := buildRequest("dynamodb", "us-east-1", "{}") - - query := req.URL.Query() - query.Set("X-Amz-Expires", "300") - req.URL.RawQuery = query.Encode() - - signedHdrs := []string{"content-length", "content-type", "host", "x-amz-date", "x-amz-meta-other-header", "x-amz-meta-other-header_with_underscore", "x-amz-security-token", "x-amz-target"} - signer := NewSigner() - signed, headers, _, err := signer.PresignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0), signedHdrs) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - expectedDate := "19700101T000000Z" - expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-target" - expectedSig := "266528f4c66b4b20807f199141c606c7aa81dd793592b4c6f8dc301c05691e54" - expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request" - - q, err := url.ParseQuery(signed[strings.Index(signed, "?"):]) - if err != nil { - t.Errorf("expect no error, got %v", err) - } - - if e, a := expectedSig, q.Get("X-Amz-Signature"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedCred, q.Get("X-Amz-Credential"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { - t.Errorf("expect %v to be empty", a) - } - if a := q.Get("X-Amz-Target"); len(a) != 0 { - t.Errorf("expect X-Amz-Target to be empty, got %v", a) - } - - for h := range strings.SplitSeq(expectedHeaders, ";") { - v := headers.Get(h) - if len(v) == 0 { - t.Errorf("expect %v, to be present in header map", h) - } - } -} - -func TestPresignBodyWithArrayRequest(t *testing.T) { - req, body := buildRequest("dynamodb", "us-east-1", "{}") - req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" - - query := req.URL.Query() - query.Set("X-Amz-Expires", "300") - req.URL.RawQuery = query.Encode() - - signedHdrs := []string{"content-length", "content-type", "host", "x-amz-date", "x-amz-meta-other-header", "x-amz-meta-other-header_with_underscore", "x-amz-security-token", "x-amz-target"} - signer := NewSigner() - signed, headers, _, err := signer.PresignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0), signedHdrs) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - - q, err := url.ParseQuery(signed[strings.Index(signed, "?"):]) - if err != nil { - t.Errorf("expect no error, got %v", err) - } - - expectedDate := "19700101T000000Z" - expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-target" - expectedSig := "f8a1f60771366686c04045b64ae1381d302c83d67d84a02567926000e3e653c4" - expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request" - - if e, a := expectedSig, q.Get("X-Amz-Signature"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedCred, q.Get("X-Amz-Credential"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { - t.Errorf("expect %v to be empty, was not", a) - } - if a := q.Get("X-Amz-Target"); len(a) != 0 { - t.Errorf("expect X-Amz-Target to be empty, got %v", a) - } - - for h := range strings.SplitSeq(expectedHeaders, ";") { - v := headers.Get(h) - if len(v) == 0 { - t.Errorf("expect %v, to be present in header map", h) - } - } -} - -func TestSignRequest(t *testing.T) { - req, body := buildRequest("dynamodb", "us-east-1", "{}") - signer := NewSigner() - signedHdrs := []string{"content-length", "content-type", "host", "x-amz-date", "x-amz-meta-other-header", "x-amz-meta-other-header_with_underscore", "x-amz-security-token", "x-amz-target"} - _, err := signer.SignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0), signedHdrs) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - - expectedDate := "19700101T000000Z" - expectedSig := "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-security-token;x-amz-target, Signature=a518299330494908a70222cec6899f6f32f297f8595f6df1776d998936652ad9" - - q := req.Header - if e, a := expectedSig, q.Get("Authorization"); e != a { - t.Errorf("expect %v, got %v", e, a) - } - if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { - t.Errorf("expect %v, got %v", e, a) - } -} - -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" - - ctx := &httpSigner{ - ServiceName: "dynamodb", - Region: "us-east-1", - Request: req, - Time: v4Internal.NewSigningTime(time.Now()), - KeyDerivator: v4Internal.NewSigningKeyDeriver(), - } - - build, err := ctx.Build() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - expected := "https://example.org/bucket/key-._~,!@#$%^&*()?Foo=a&Foo=m&Foo=o&Foo=z" - if e, a := expected, build.Request.URL.String(); e != a { - t.Errorf("expect %v, got %v", e, a) - } -} - -func TestSigner_SignHTTP_NoReplaceRequestBody(t *testing.T) { - req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") - req.Body = io.NopCloser(bytes.NewReader([]byte{})) - - s := NewSigner() - - origBody := req.Body - - _, err := s.SignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now(), []string{}) - if err != nil { - t.Fatalf("expect no error, got %v", err) - } - - if req.Body != origBody { - t.Errorf("expect request body to not be chagned") - } -} - -func TestRequestHost(t *testing.T) { - req, _ := buildRequest("dynamodb", "us-east-1", "{}") - req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" - req.Host = "myhost" - - query := req.URL.Query() - query.Set("X-Amz-Expires", "5") - req.URL.RawQuery = query.Encode() - - ctx := &httpSigner{ - ServiceName: "dynamodb", - Region: "us-east-1", - Request: req, - Time: v4Internal.NewSigningTime(time.Now()), - KeyDerivator: v4Internal.NewSigningKeyDeriver(), - } - - build, err := ctx.Build() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if !strings.Contains(build.CanonicalString, "host:"+req.Host) { - t.Errorf("canonical host header invalid") - } -} - -func TestSign_buildCanonicalHeadersContentLengthPresent(t *testing.T) { - body := `{"description": "this is a test"}` - req, _ := buildRequest("dynamodb", "us-east-1", body) - req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" - req.Host = "myhost" - - contentLength := fmt.Sprintf("%d", len([]byte(body))) - req.Header.Add("Content-Length", contentLength) - - query := req.URL.Query() - query.Set("X-Amz-Expires", "5") - req.URL.RawQuery = query.Encode() - - ctx := &httpSigner{ - ServiceName: "dynamodb", - Region: "us-east-1", - Request: req, - Time: v4Internal.NewSigningTime(time.Now()), - KeyDerivator: v4Internal.NewSigningKeyDeriver(), - } - - _, err := ctx.Build() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - //if !strings.Contains(build.CanonicalString, "content-length:"+contentLength+"\n") { - // t.Errorf("canonical header content-length invalid") - //} -} - -func TestSign_buildCanonicalHeaders(t *testing.T) { - serviceName := "mockAPI" - region := "mock-region" - endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" - - req, err := http.NewRequest("POST", endpoint, nil) - if err != nil { - t.Fatalf("failed to create request, %v", err) - } - - req.Header.Set("FooInnerSpace", " inner space ") - req.Header.Set("FooLeadingSpace", " leading-space") - req.Header.Add("FooMultipleSpace", "no-space") - req.Header.Add("FooMultipleSpace", "\ttab-space") - req.Header.Add("FooMultipleSpace", "trailing-space ") - req.Header.Set("FooNoSpace", "no-space") - req.Header.Set("FooTabSpace", "\ttab-space\t") - req.Header.Set("FooTrailingSpace", "trailing-space ") - req.Header.Set("FooWrappedSpace", " wrapped-space ") - - ctx := &httpSigner{ - ServiceName: serviceName, - Region: region, - Request: req, - Time: v4Internal.NewSigningTime(time.Date(2021, 10, 20, 12, 42, 0, 0, time.UTC)), - KeyDerivator: v4Internal.NewSigningKeyDeriver(), - } - - build, err := ctx.Build() - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - expectCanonicalString := strings.Join([]string{ - `POST`, - `/`, - ``, - `fooinnerspace:inner space`, - `fooleadingspace:leading-space`, - `foomultiplespace:no-space,tab-space,trailing-space`, - `foonospace:no-space`, - `footabspace:tab-space`, - `footrailingspace:trailing-space`, - `foowrappedspace:wrapped-space`, - `host:mockAPI.mock-region.amazonaws.com`, - `x-amz-date:20211020T124200Z`, - ``, - `fooinnerspace;fooleadingspace;foomultiplespace;foonospace;footabspace;footrailingspace;foowrappedspace;host;x-amz-date`, - ``, - }, "\n") - if diff := cmp.Diff(expectCanonicalString, build.CanonicalString); diff != "" { - t.Errorf("expect match, got\n%s", diff) - } -} - -func BenchmarkPresignRequest(b *testing.B) { - signer := NewSigner() - req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") - - query := req.URL.Query() - query.Set("X-Amz-Expires", "5") - req.URL.RawQuery = query.Encode() - - for i := 0; i < b.N; i++ { - signer.PresignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now(), []string{}) - } -} - -func BenchmarkSignRequest(b *testing.B) { - signer := NewSigner() - req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") - for i := 0; i < b.N; i++ { - _, _ = signer.SignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now(), []string{}) - } -} diff --git a/backend/azure/azure.go b/backend/azure/azure.go index b18708f7..361e6828 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -1107,22 +1107,7 @@ func (az *Azure) DeleteObjects(ctx context.Context, input *s3.DeleteObjectsInput if err == nil { delResult = append(delResult, types.DeletedObject{Key: obj.Key}) } else { - serr, ok := err.(s3err.S3Error) - if ok { - code := serr.BaseError().Code - message := serr.BaseError().Description - errs = append(errs, types.Error{ - Key: obj.Key, - Code: &code, - Message: &message, - }) - } else { - errs = append(errs, types.Error{ - Key: obj.Key, - Code: backend.GetPtrFromString("InternalError"), - Message: backend.GetPtrFromString(err.Error()), - }) - } + errs = append(errs, s3err.ObjectDeleteError(obj.Key, obj.VersionId, err)) } } diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 6259c74c..77c5cbbc 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -4750,22 +4750,7 @@ func (p *Posix) DeleteObjects(ctx context.Context, input *s3.DeleteObjectsInput) delResult = append(delResult, delEntity) } else { - serr, ok := err.(s3err.S3Error) - if ok { - errCode := serr.BaseError().Code - errMessage := serr.BaseError().Code - errs = append(errs, types.Error{ - Key: obj.Key, - Code: &errCode, - Message: &errMessage, - }) - } else { - errs = append(errs, types.Error{ - Key: obj.Key, - Code: backend.GetPtrFromString("InternalError"), - Message: backend.GetPtrFromString(err.Error()), - }) - } + errs = append(errs, s3err.ObjectDeleteError(obj.Key, obj.VersionId, err)) } } @@ -7049,17 +7034,23 @@ func (p *Posix) ListBucketsAndOwners(ctx context.Context) (buckets []s3response. return buckets, nil } +// NormalizeObjectKey resolves object relative to bucket the same way the +// filesystem will (collapsing ".."/"." segments, catching a traversal +// attempt that escapes bucket), but the result names an S3 key, not a host +// path: on Windows filepath.Join/Rel would return it with "\" separators, +// which callers building a policy-resource ARN or match string must never +// see, so it's converted back to "/" before returning. func (p *Posix) NormalizeObjectKey(bucket, object string) string { fullPath := filepath.Join(bucket, object) key, err := filepath.Rel(filepath.Clean(bucket), fullPath) if err != nil { - return fullPath + return filepath.ToSlash(fullPath) } if key == "." { return "" } - return key + return filepath.ToSlash(key) } func (p *Posix) storeChecksums(f *os.File, bucket, object string, chs s3response.Checksum) error { diff --git a/cmd/internal/gwcli/iam.go b/cmd/internal/gwcli/iam.go index 183aa5f0..496dd8bf 100644 --- a/cmd/internal/gwcli/iam.go +++ b/cmd/internal/gwcli/iam.go @@ -114,6 +114,31 @@ func IAMCommand() *cli.Command { Usage: "reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection", EnvVars: []string{"VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH"}, }, + &cli.StringSliceFlag{ + Name: "private-ports", + Usage: "private endpoint listen address: a unix socket path, or :/: when mTLS (--private-cert/--private-cert-key/--private-client-ca) is also configured — refuses to start otherwise (can be specified multiple times)", + EnvVars: []string{"VGW_IAM_PRIVATE_PORTS"}, + }, + &cli.StringFlag{ + Name: "private-cert", + Usage: "TLS server certificate for the private endpoint listener (required for a non-unix-socket --private-ports address)", + EnvVars: []string{"VGW_IAM_PRIVATE_CERT"}, + }, + &cli.StringFlag{ + Name: "private-cert-key", + Usage: "TLS private key for --private-cert", + EnvVars: []string{"VGW_IAM_PRIVATE_CERT_KEY"}, + }, + &cli.StringFlag{ + Name: "private-client-ca", + Usage: "PEM-encoded CA bundle used to verify the S3 gateway's client certificate on the private endpoint listener (required for a non-unix-socket --private-ports address, together with --private-cert/--private-cert-key)", + EnvVars: []string{"VGW_IAM_PRIVATE_CLIENT_CA"}, + }, + &cli.StringFlag{ + Name: "private-socket-perm", + Usage: "octal file-mode permission for a file-backed unix-socket --private-ports address (e.g. '0660'); no effect on TCP or abstract-namespace sockets", + EnvVars: []string{"VGW_IAM_PRIVATE_SOCKET_PERM"}, + }, }, } } diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index fbd6fa54..c1bf9783 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -51,6 +51,11 @@ func runIAM(ctx *cli.Context) error { KeepAlive: keepAlive, HealthPath: healthPath, SocketPerm: socketPerm, + PrivatePorts: ctx.StringSlice("private-ports"), + PrivateCertFile: ctx.String("private-cert"), + PrivateKeyFile: ctx.String("private-cert-key"), + PrivateClientCAFile: ctx.String("private-client-ca"), + PrivateSocketPerm: ctx.String("private-socket-perm"), IAMDir: ctx.String("dir"), VaultEndpointURL: ctx.String("vault-endpoint-url"), VaultNamespace: ctx.String("vault-namespace"), diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 2a04adcc..970a820b 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -27,77 +27,84 @@ import ( "github.com/versity/versitygw/cmd/internal/gwcli" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" - "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/internal/netutil" ) var ( - ports []string - admPorts []string - region string - maxConnections, maxRequests int - adminMaxConnections, adminMaxRequests int - corsAllowOrigin string - admCertFile, admKeyFile string - certFile, keyFile string - kafkaURL, kafkaTopic, kafkaKey string - natsURL, natsTopic string - rabbitmqURL, rabbitmqExchange string - rabbitmqRoutingKey string - eventWebhookURL string - eventConfigFilePath string - logWebhookURL, accessLog string - adminLogFile string - healthPath string - virtualDomain string - logLevel string - debug bool - keepAlive bool - pprof string - quiet bool - readonly bool - iamDir string - ldapURL, ldapBindDN, ldapPassword string - ldapQueryBase, ldapObjClasses string - ldapAccessAtr, ldapSecAtr, ldapRoleAtr string - ldapUserIdAtr, ldapGroupIdAtr string - ldapProjectIdAtr string - ldapTLSSkipVerify bool - vaultEndpointURL, vaultNamespace string - vaultSecretStoragePath string - vaultSecretStorageNamespace string - vaultAuthMethod, vaultAuthNamespace string - vaultMountPath string - vaultRootToken, vaultRoleId string - vaultRoleSecret, vaultServerCert string - vaultClientCert, vaultClientCertKey string - s3IamAccess, s3IamSecret string - s3IamRegion, s3IamBucket string - s3IamEndpoint string - s3IamSslNoVerify bool - iamCacheDisable bool - iamCacheTTL int - iamCachePrune int - metricsService string - statsdServers string - dogstatsServers string - ipaHost, ipaVaultName string - ipaUser, ipaPassword string - ipaInsecure bool - iamDebug bool - webuiPorts []string - webuiCertFile, webuiKeyFile string - webuiNoTLS bool - webuiGateways []string - webuiAdminGateways []string - webuiPathPrefix string - webuiS3Prefix string - websitePorts []string - websiteDomain string - websiteCertFile, websiteKeyFile string - websiteNoTLS bool - disableACLs bool - mpMaxParts int - socketPerm string + ports []string + admPorts []string + region string + maxConnections, maxRequests int + adminMaxConnections, adminMaxRequests int + corsAllowOrigin string + admCertFile, admKeyFile string + certFile, keyFile string + kafkaURL, kafkaTopic, kafkaKey string + natsURL, natsTopic string + rabbitmqURL, rabbitmqExchange string + rabbitmqRoutingKey string + eventWebhookURL string + eventConfigFilePath string + logWebhookURL, accessLog string + adminLogFile string + healthPath string + virtualDomain string + logLevel string + debug bool + keepAlive bool + pprof string + quiet bool + readonly bool + iamDir string + ldapURL, ldapBindDN, ldapPassword string + ldapQueryBase, ldapObjClasses string + ldapAccessAtr, ldapSecAtr, ldapRoleAtr string + ldapUserIdAtr, ldapGroupIdAtr string + ldapProjectIdAtr string + ldapTLSSkipVerify bool + vaultEndpointURL, vaultNamespace string + vaultSecretStoragePath string + vaultSecretStorageNamespace string + vaultAuthMethod, vaultAuthNamespace string + vaultMountPath string + vaultRootToken, vaultRoleId string + vaultRoleSecret, vaultServerCert string + vaultClientCert, vaultClientCertKey string + s3IamAccess, s3IamSecret string + s3IamRegion, s3IamBucket string + s3IamEndpoint string + s3IamSslNoVerify bool + iamCacheDisable bool + iamCacheTTL int + iamCachePrune int + metricsService string + statsdServers string + dogstatsServers string + ipaHost, ipaVaultName string + ipaUser, ipaPassword string + ipaInsecure bool + standaloneIAMEndpoint string + standaloneIAMAccess, standaloneIAMSecret string + standaloneClientCert, standaloneClientCertKey string + standaloneServerCA string + standaloneDefaultUserID int + standaloneDefaultGroupID int + standaloneDefaultProjectID int + iamDebug bool + webuiPorts []string + webuiCertFile, webuiKeyFile string + webuiNoTLS bool + webuiGateways []string + webuiAdminGateways []string + webuiPathPrefix string + webuiS3Prefix string + websitePorts []string + websiteDomain string + websiteCertFile, websiteKeyFile string + websiteNoTLS bool + disableACLs bool + mpMaxParts int + socketPerm string ) var ( @@ -163,16 +170,16 @@ documentation can be found in the GitHub wiki.`, // Resolve relative UNIX socket paths to absolute before any backend // (e.g. posix) can change the working directory via os.Chdir. var err error - if ports, err = utils.AbsSocketPaths(ports); err != nil { + if ports, err = netutil.AbsSocketPaths(ports); err != nil { return err } - if admPorts, err = utils.AbsSocketPaths(admPorts); err != nil { + if admPorts, err = netutil.AbsSocketPaths(admPorts); err != nil { return err } - if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { + if webuiPorts, err = netutil.AbsSocketPaths(webuiPorts); err != nil { return err } - if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil { + if websitePorts, err = netutil.AbsSocketPaths(websitePorts); err != nil { return err } return nil @@ -797,6 +804,60 @@ func initFlags() []cli.Flag { EnvVars: []string{"VGW_IPA_INSECURE"}, Destination: &ipaInsecure, }, + &cli.StringFlag{ + Name: "iam-standalone-endpoint", + Usage: "standalone IAM service private-endpoint address: a unix socket path, or : when mTLS (--iam-standalone-client-cert/-key/--iam-standalone-server-ca) is also configured", + EnvVars: []string{"VGW_IAM_STANDALONE_ENDPOINT"}, + Destination: &standaloneIAMEndpoint, + }, + &cli.StringFlag{ + Name: "iam-standalone-access", + Usage: "access key this gateway signs its own calls to the standalone IAM service with (defaults to --access/root)", + EnvVars: []string{"VGW_IAM_STANDALONE_ACCESS"}, + Destination: &standaloneIAMAccess, + }, + &cli.StringFlag{ + Name: "iam-standalone-secret", + Usage: "secret key this gateway signs its own calls to the standalone IAM service with (defaults to --secret/root)", + EnvVars: []string{"VGW_IAM_STANDALONE_SECRET"}, + Destination: &standaloneIAMSecret, + }, + &cli.StringFlag{ + Name: "iam-standalone-client-cert", + Usage: "client TLS certificate this gateway presents to the standalone IAM service (required for a non-unix-socket --iam-standalone-endpoint)", + EnvVars: []string{"VGW_IAM_STANDALONE_CLIENT_CERT"}, + Destination: &standaloneClientCert, + }, + &cli.StringFlag{ + Name: "iam-standalone-client-cert-key", + Usage: "private key for --iam-standalone-client-cert", + EnvVars: []string{"VGW_IAM_STANDALONE_CLIENT_CERT_KEY"}, + Destination: &standaloneClientCertKey, + }, + &cli.StringFlag{ + Name: "iam-standalone-server-ca", + Usage: "PEM-encoded CA bundle used to verify the standalone IAM service's server certificate (required for a non-unix-socket --iam-standalone-endpoint)", + EnvVars: []string{"VGW_IAM_STANDALONE_SERVER_CA"}, + Destination: &standaloneServerCA, + }, + &cli.IntFlag{ + Name: "iam-standalone-default-uid", + Usage: "POSIX uid assigned to every account resolved through the standalone IAM backend (it has no per-user POSIX identity of its own)", + EnvVars: []string{"VGW_IAM_STANDALONE_DEFAULT_UID"}, + Destination: &standaloneDefaultUserID, + }, + &cli.IntFlag{ + Name: "iam-standalone-default-gid", + Usage: "POSIX gid assigned to every account resolved through the standalone IAM backend", + EnvVars: []string{"VGW_IAM_STANDALONE_DEFAULT_GID"}, + Destination: &standaloneDefaultGroupID, + }, + &cli.IntFlag{ + Name: "iam-standalone-default-project-id", + Usage: "project id assigned to every account resolved through the standalone IAM backend", + EnvVars: []string{"VGW_IAM_STANDALONE_DEFAULT_PROJECT_ID"}, + Destination: &standaloneDefaultProjectID, + }, &cli.IntFlag{ Name: "mp-max-parts", Usage: "maximum number of parts allowed in a multipart upload", @@ -920,6 +981,15 @@ func runGateway(ctx context.Context, be backend.Backend) error { IpaUser: ipaUser, IpaPassword: ipaPassword, IpaInsecure: ipaInsecure, + StandaloneIAMEndpoint: standaloneIAMEndpoint, + StandaloneIAMAccess: standaloneIAMAccess, + StandaloneIAMSecret: standaloneIAMSecret, + StandaloneClientCert: standaloneClientCert, + StandaloneClientCertKey: standaloneClientCertKey, + StandaloneServerCA: standaloneServerCA, + StandaloneDefaultUserID: standaloneDefaultUserID, + StandaloneDefaultGroupID: standaloneDefaultGroupID, + StandaloneDefaultProjectID: standaloneDefaultProjectID, AccessLog: accessLog, LogWebhookURL: logWebhookURL, AdminLogFile: adminLogFile, diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 075305fe..61190d76 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -26,6 +26,7 @@ var ( awsID string awsSecret string endpoint string + iamEndpoint string websiteSchemeTest string websiteDomainTest string websitePortTest string @@ -82,6 +83,12 @@ func initTestFlags() []cli.Flag { Destination: &endpoint, Aliases: []string{"e"}, }, + &cli.StringFlag{ + Name: "iam-endpoint", + Usage: "standalone IAM/STS service endpoint, when it is a separate process from the s3 endpoint (defaults to --endpoint)", + Destination: &iamEndpoint, + Aliases: []string{"ie"}, + }, &cli.BoolFlag{ Name: "host-style", Usage: "Use host-style bucket addressing", @@ -212,6 +219,24 @@ func initTestCommands() []*cli.Command { Usage: "Tests gateway access control with bucket ACLs and Policies", Action: getAction(integration.TestAccessControl), }, + { + Name: "s3-iam", + Usage: "Tests s3 gateway access control backed by the standalone IAM service", + Description: `Runs the access-control tests for an s3 gateway configured with --iam-standalone-endpoint: + IAM user identity policies, their interaction with bucket policies, governance-retention + bypass, and bucket creation. Requires --iam-endpoint pointing at the IAM service's + control-plane API, since the tests create the users and policies they then exercise.`, + Action: getAction(integration.TestS3IAMAccessControl), + }, + { + Name: "s3-iam-session", + Usage: "Tests s3 gateway access control for assumed-role session credentials", + Description: `Runs the role/session access-control tests against an s3 gateway backed by the + standalone IAM service. Every test mints real temporary credentials via + AssumeRoleWithWebIdentity against GitHub Actions' OIDC issuer, so the whole group skips + itself outside a GitHub Actions job holding id-token: write permission.`, + Action: getAction(integration.TestS3IAMSessionAccessControl), + }, { Name: "noacl", Usage: "Tests gateway in ACL-disabled mode", @@ -434,6 +459,7 @@ func getAction(tf testFunc) func(ctx *cli.Context) error { integration.WithSecret(awsSecret), integration.WithRegion(region), integration.WithEndpoint(endpoint), + integration.WithIAMEndpoint(iamEndpoint), integration.WithTLSStatus(tlsStatus), } if testDebug { @@ -484,6 +510,7 @@ func extractIntTests() (commands []*cli.Command) { integration.WithSecret(awsSecret), integration.WithRegion(region), integration.WithEndpoint(endpoint), + integration.WithIAMEndpoint(iamEndpoint), integration.WithTLSStatus(tlsStatus), } if testDebug { diff --git a/cmd/vgwrdma/main.go b/cmd/vgwrdma/main.go index 03c344f0..0596ec9c 100644 --- a/cmd/vgwrdma/main.go +++ b/cmd/vgwrdma/main.go @@ -31,9 +31,9 @@ import ( "github.com/versity/versitygw/cumiddleware" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/rdma" "github.com/versity/versitygw/s3api" - "github.com/versity/versitygw/s3api/utils" ) var ( @@ -184,16 +184,16 @@ documentation can be found in the GitHub wiki.`, // Resolve relative UNIX socket paths to absolute before any backend // (e.g. posix) can change the working directory via os.Chdir. var err error - if ports, err = utils.AbsSocketPaths(ports); err != nil { + if ports, err = netutil.AbsSocketPaths(ports); err != nil { return err } - if admPorts, err = utils.AbsSocketPaths(admPorts); err != nil { + if admPorts, err = netutil.AbsSocketPaths(admPorts); err != nil { return err } - if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { + if webuiPorts, err = netutil.AbsSocketPaths(webuiPorts); err != nil { return err } - if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil { + if websitePorts, err = netutil.AbsSocketPaths(websitePorts); err != nil { return err } diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index ba5e66ab..847a6b2a 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -36,6 +36,7 @@ import ( "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/metrics" "github.com/versity/versitygw/s3api" "github.com/versity/versitygw/s3api/middlewares" @@ -160,15 +161,16 @@ type Config struct { // IAM Backends // - // The gateway supports five external IAM backends. At most one may be + // The gateway supports six external IAM backends. At most one may be // active at a time. When the fields for more than one backend are // populated, the first match in the following priority order wins: // - // 1. IAMDir -- local directory - // 2. LDAPServerURL -- LDAP - // 3. S3IAMEndpoint -- S3-backed - // 4. VaultEndpointURL -- HashiCorp Vault - // 5. IpaHost -- FreeIPA + // 1. StandaloneIAMEndpoint -- standalone IAM service + // 2. IAMDir -- local directory + // 3. LDAPServerURL -- LDAP + // 4. S3IAMEndpoint -- S3-backed + // 5. VaultEndpointURL -- HashiCorp Vault + // 6. IpaHost -- FreeIPA // // Configuring an IAM backend is optional. When none of the trigger fields // above are set, the gateway runs in single-account mode: only the root @@ -280,6 +282,44 @@ type Config struct { // connection. IpaInsecure bool + // Standalone IAM service backend. This is an AWS compatible IAM system + // Activated when StandaloneIAMEndpoint is non-empty. Unlike the other + // backends, this one never holds a plaintext secret for any account but + // its own signing identity and the local root account — every other account's + // secret and inline policy documents stay inside the IAM service process. + // Because of that, user management (CreateUser/UpdateUser/DeleteUser/ListUsers) + // is unavailable through this gateway's own admin API when this + // backend is active; manage users via the standalone IAM service's own + // control-plane API instead. + + // StandaloneIAMEndpoint is either a "host:port" TCP address (mTLS + // required — see StandaloneClientCert/ClientCertKey/ServerCA) or a + // unix socket path for the standalone IAM service's private endpoints. + StandaloneIAMEndpoint string + // StandaloneIAMAccess/StandaloneIAMSecret are this gateway's own + // signing identity for its calls to the private endpoints — not a + // fetched account. Both default to RootUserAccess/RootUserSecret when + // unset. + StandaloneIAMAccess string + StandaloneIAMSecret string + // StandaloneClientCert/ClientCertKey are this gateway's client + // certificate/key for outbound mTLS to the private endpoints. Required + // (together with StandaloneServerCA) unless StandaloneIAMEndpoint is a + // unix socket. + StandaloneClientCert string + StandaloneClientCertKey string + // StandaloneServerCA verifies the standalone IAM service's server + // certificate. + StandaloneServerCA string + // StandaloneDefaultUserID/GroupID/ProjectID are the POSIX uid/gid/ + // project-id assigned to every account resolved through this backend. + // The standalone IAM service's user model has no per-user POSIX + // identity concept, so every standalone-backed account shares these + // one configured values for backend file-ownership purposes. + StandaloneDefaultUserID int + StandaloneDefaultGroupID int + StandaloneDefaultProjectID int + // IAM Cache // // The gateway maintains an in-memory cache of IAM account lookups to @@ -600,7 +640,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.KeyFile == "" { return fmt.Errorf("TLS cert specified without key file") } - cs := utils.NewCertStorage() + cs := netutil.NewCertStorage() if err := cs.SetCertificate(cfg.CertFile, cfg.KeyFile); err != nil { return fmt.Errorf("tls: load certs: %v", err) } @@ -681,6 +721,15 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { IpaUser: cfg.IpaUser, IpaPassword: cfg.IpaPassword, IpaInsecure: cfg.IpaInsecure, + StandaloneIAMEndpoint: cfg.StandaloneIAMEndpoint, + StandaloneIAMAccess: cfg.StandaloneIAMAccess, + StandaloneIAMSecret: cfg.StandaloneIAMSecret, + StandaloneClientCert: cfg.StandaloneClientCert, + StandaloneClientCertKey: cfg.StandaloneClientCertKey, + StandaloneServerCA: cfg.StandaloneServerCA, + StandaloneDefaultUserID: cfg.StandaloneDefaultUserID, + StandaloneDefaultGroupID: cfg.StandaloneDefaultGroupID, + StandaloneDefaultProjectID: cfg.StandaloneDefaultProjectID, }) if err != nil { return fmt.Errorf("setup iam: %w", err) @@ -800,7 +849,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.AdminKeyFile == "" { return fmt.Errorf("TLS cert specified without key file") } - cs := utils.NewCertStorage() + cs := netutil.NewCertStorage() if err = cs.SetCertificate(cfg.AdminCertFile, cfg.AdminKeyFile); err != nil { return fmt.Errorf("tls: load certs: %v", err) } @@ -824,7 +873,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { webTLSKey := "" if len(cfg.WebuiPorts) > 0 { for _, addr := range cfg.WebuiPorts { - if utils.IsUnixSocketPath(addr) { + if netutil.IsUnixSocketPath(addr) { continue } _, webPrt, err := net.SplitHostPort(addr) @@ -855,7 +904,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if webTLSKey == "" { return fmt.Errorf("webui TLS cert specified without key file") } - cs := utils.NewCertStorage() + cs := netutil.NewCertStorage() if err := cs.SetCertificate(webTLSCert, webTLSKey); err != nil { return fmt.Errorf("tls: load certs: %v", err) } @@ -923,7 +972,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { wsTLSKey := "" if len(cfg.WebsitePorts) > 0 { for _, addr := range cfg.WebsitePorts { - if utils.IsUnixSocketPath(addr) { + if netutil.IsUnixSocketPath(addr) { continue } _, wsPrt, err := net.SplitHostPort(addr) @@ -954,7 +1003,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if wsTLSKey == "" { return fmt.Errorf("website TLS cert specified without key file") } - cs := utils.NewCertStorage() + cs := netutil.NewCertStorage() if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil { return fmt.Errorf("tls: load certs: %v", err) } @@ -1146,7 +1195,7 @@ func (cfg Config) printBanner() { interfaceMap := make(map[string]bool) for _, portSpec := range cfg.Ports { - if utils.IsUnixSocketPath(portSpec) { + if netutil.IsUnixSocketPath(portSpec) { allPorts = append(allPorts, portSpec) if !interfaceMap[portSpec] { interfaceMap[portSpec] = true @@ -1182,7 +1231,7 @@ func (cfg Config) printBanner() { var allAdmInterfaces []string admInterfaceMap := make(map[string]bool) for _, admPort := range cfg.AdminPorts { - if utils.IsUnixSocketPath(admPort) { + if netutil.IsUnixSocketPath(admPort) { if !admInterfaceMap[admPort] { admInterfaceMap[admPort] = true allAdmInterfaces = append(allAdmInterfaces, admPort) @@ -1215,7 +1264,7 @@ func (cfg Config) printBanner() { var urls []string for _, addrPort := range allInterfaces { - if utils.IsUnixSocketPath(addrPort) { + if netutil.IsUnixSocketPath(addrPort) { urls = append(urls, "unix:"+addrPort) continue } @@ -1233,7 +1282,7 @@ func (cfg Config) printBanner() { var boundHost string if len(cfg.Ports) == 1 { - if utils.IsUnixSocketPath(cfg.Ports[0]) { + if netutil.IsUnixSocketPath(cfg.Ports[0]) { boundHost = fmt.Sprintf("(unix socket: %s)", cfg.Ports[0]) } else { hst, prt, _ := net.SplitHostPort(cfg.Ports[0]) @@ -1267,7 +1316,7 @@ func (cfg Config) printBanner() { if len(allAdmInterfaces) > 0 { lines = append(lines, centerText(""), leftText("Admin service listening on:")) for _, addrPort := range allAdmInterfaces { - if utils.IsUnixSocketPath(addrPort) { + if netutil.IsUnixSocketPath(addrPort) { lines = append(lines, leftText(" unix:"+addrPort)) continue } @@ -1292,7 +1341,7 @@ func (cfg Config) printBanner() { if strings.TrimSpace(webuiAddr) == "" { continue } - if utils.IsUnixSocketPath(webuiAddr) { + if netutil.IsUnixSocketPath(webuiAddr) { if !webInterfaceMap[webuiAddr] { webInterfaceMap[webuiAddr] = true allWebInterfaces = append(allWebInterfaces, webuiAddr) @@ -1321,7 +1370,7 @@ func (cfg Config) printBanner() { if len(allWebInterfaces) > 0 { lines = append(lines, centerText(""), leftText("WebUI listening on:")) for _, addrPort := range allWebInterfaces { - if utils.IsUnixSocketPath(addrPort) { + if netutil.IsUnixSocketPath(addrPort) { lines = append(lines, leftText(" unix:"+addrPort)) continue } @@ -1363,7 +1412,7 @@ func (cfg Config) printBanner() { if strings.TrimSpace(websiteAddr) == "" { continue } - if utils.IsUnixSocketPath(websiteAddr) { + if netutil.IsUnixSocketPath(websiteAddr) { if !websiteInterfaceMap[websiteAddr] { websiteInterfaceMap[websiteAddr] = true allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr) @@ -1399,7 +1448,7 @@ func (cfg Config) printBanner() { leftText("Website endpoint listening on:"+domainInfo), ) for _, addrPort := range allWebsiteInterfaces { - if utils.IsUnixSocketPath(addrPort) { + if netutil.IsUnixSocketPath(addrPort) { lines = append(lines, leftText(" unix:"+addrPort)) continue } @@ -1439,11 +1488,11 @@ func leftText(text string) string { // getMatchingIPs returns all IP addresses that the server will listen on // for the given address specification. func getMatchingIPs(spec string) ([]string, error) { - if utils.IsUnixSocketPath(spec) { + if netutil.IsUnixSocketPath(spec) { return []string{spec}, nil } - ips, err := utils.ResolveHostnameIPs(spec) + ips, err := netutil.ResolveHostnameIPs(spec) if err != nil { return nil, fmt.Errorf("resolve hostname: %v", err) } @@ -1497,7 +1546,7 @@ func getAllLocalIPs() ([]string, error) { } func buildServiceURLs(spec string, ssl bool) ([]string, error) { - if utils.IsUnixSocketPath(spec) { + if netutil.IsUnixSocketPath(spec) { return nil, nil } @@ -1628,7 +1677,7 @@ func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) e var allSpecs []portSpec for _, p := range ports { - if utils.IsUnixSocketPath(p) { + if netutil.IsUnixSocketPath(p) { allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "s3"}) continue } @@ -1645,7 +1694,7 @@ func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) e } for _, p := range admPorts { - if utils.IsUnixSocketPath(p) { + if netutil.IsUnixSocketPath(p) { allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "admin"}) continue } @@ -1662,7 +1711,7 @@ func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) e } for _, p := range webuiPorts { - if utils.IsUnixSocketPath(p) { + if netutil.IsUnixSocketPath(p) { allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "webui"}) continue } @@ -1679,7 +1728,7 @@ func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) e } for _, p := range websitePorts { - if utils.IsUnixSocketPath(p) { + if netutil.IsUnixSocketPath(p) { allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"}) continue } diff --git a/embedgw/iam.go b/embedgw/iam.go index 5d638171..d8180ade 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -26,8 +26,9 @@ import ( "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi" + "github.com/versity/versitygw/iamapi/private" "github.com/versity/versitygw/iamapi/storage" - "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/internal/netutil" ) const iamTitle = "VersityGW IAM API" @@ -79,6 +80,28 @@ type IAMConfig struct { // abstract namespace sockets. SocketPerm string + // PrivatePorts is the list of listening addresses for the standalone + // IAM service's private endpoints (derive-signing-key, evaluate-policy, resolve-identity) + // — see private.PrivateAPI. Each address must be a unix socket, or a TCP + // address with PrivateCertFile/PrivateKeyFile/PrivateClientCAFile all + // set (mTLS with mandatory client-certificate verification); anything + // else fails startup rather than serving these endpoints in the clear. + // Empty disables the private endpoints entirely. + PrivatePorts []string + // PrivateCertFile/PrivateKeyFile are the private listener's own TLS + // server certificate, distinct from CertFile/KeyFile (the public + // control-plane listener's certificate) since the two listeners have + // different security requirements. + PrivateCertFile string + PrivateKeyFile string + // PrivateClientCAFile verifies the S3 gateway's client certificate on + // the private listener. Required, together with PrivateCertFile/ + // PrivateKeyFile, for any non-unix-socket PrivatePorts address. + PrivateClientCAFile string + // PrivateSocketPerm is the octal file-mode string for a file-backed + // unix-socket PrivatePorts address. + PrivateSocketPerm string + // IAMDir enables local file-backed IAM API storage. Set to the directory // path where the IAM API user database is stored. IAMDir string @@ -132,6 +155,56 @@ type IAMConfig struct { DisableOIDCThumbprintAutoFetch bool } +// newPrivateAPI builds the standalone IAM service's private endpoint set +// and the TLS options ServeMultiPort will enforce (mTLS, or nothing at all +// for a unix-socket-only deployment — see netutil.RequireSecureTransport). +func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*private.PrivateAPI, netutil.TLSOptions, error) { + allSet := cfg.PrivateCertFile != "" && cfg.PrivateKeyFile != "" && cfg.PrivateClientCAFile != "" + noneSet := cfg.PrivateCertFile == "" && cfg.PrivateKeyFile == "" && cfg.PrivateClientCAFile == "" + if !allSet && !noneSet { + return nil, netutil.TLSOptions{}, fmt.Errorf("--private-cert, --private-cert-key, and --private-client-ca must all be set together, or all left empty for a unix-socket-only private listener") + } + + var tlsOpts netutil.TLSOptions + if allSet { + cs := netutil.NewCertStorage() + if err := cs.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile); err != nil { + return nil, netutil.TLSOptions{}, fmt.Errorf("private listener: load certs: %w", err) + } + pool, err := netutil.LoadCACertPool(cfg.PrivateClientCAFile) + if err != nil { + return nil, netutil.TLSOptions{}, fmt.Errorf("private listener: %w", err) + } + tlsOpts = netutil.TLSOptions{ + GetCertificate: cs.GetCertificate, + ClientCAs: pool, + RequireClientCert: true, + } + } + + var privOpts []private.PrivateAPIOption + if cfg.PrivateSocketPerm != "" { + perm, err := strconv.ParseUint(cfg.PrivateSocketPerm, 8, 32) + if err != nil { + return nil, netutil.TLSOptions{}, fmt.Errorf("invalid PrivateSocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.PrivateSocketPerm, err) + } + privOpts = append(privOpts, private.WithPrivateSocketPerm(os.FileMode(perm))) + } + if cfg.Quiet { + privOpts = append(privOpts, private.WithPrivateQuiet()) + } + + p, err := private.New(store, iamapi.RootCredentials{ + Access: cfg.RootUserAccess, + Secret: cfg.RootUserSecret, + }, privOpts...) + if err != nil { + return nil, netutil.TLSOptions{}, fmt.Errorf("init private IAM API: %w", err) + } + + return p, tlsOpts, nil +} + var iamAPIRunning atomic.Bool // RunIAMAPI starts the VersityGW IAM API with the supplied configuration. It @@ -194,10 +267,6 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { 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)) @@ -233,20 +302,38 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { opts = append(opts, iamapi.WithTLS(cs)) } - server, err := iamapi.New(store, opts...) + server, err := iamapi.New(store, iamapi.RootCredentials{ + Access: cfg.RootUserAccess, + Secret: cfg.RootUserSecret, + }, opts...) if err != nil { return fmt.Errorf("init IAM API server: %w", err) } + var privateAPI *private.PrivateAPI + var privateTLSOpts netutil.TLSOptions + if len(cfg.PrivatePorts) > 0 { + privateAPI, privateTLSOpts, err = newPrivateAPI(store, cfg) + if err != nil { + return err + } + } + if !cfg.Quiet { cfg.printBanner() } - errCh := make(chan error, 1) + errCh := make(chan error, 2) go func() { errCh <- server.ServeMultiPort(cfg.Ports) }() + if privateAPI != nil { + go func() { + errCh <- privateAPI.ServeMultiPort(cfg.PrivatePorts, privateTLSOpts) + }() + } + var sigHup <-chan struct{} if cfg.SigHup != nil { sigHup = cfg.SigHup @@ -277,6 +364,11 @@ Loop: if err := server.Shutdown(); err != nil { fmt.Fprintf(os.Stderr, "shutdown IAM API server: %v\n", err) } + if privateAPI != nil { + if err := privateAPI.Shutdown(); err != nil { + fmt.Fprintf(os.Stderr, "shutdown private IAM API server: %v\n", err) + } + } return saveErr } @@ -310,6 +402,16 @@ func (cfg IAMConfig) printBanner() { lines = append(lines, leftText(" "+u)) } + if len(cfg.PrivatePorts) > 0 { + privateInterfaces, _ := resolveIAMBannerInterfaces(cfg.PrivatePorts) + if len(privateInterfaces) > 0 { + lines = append(lines, centerText(""), leftText("IAM private service listening on:")) + for _, u := range buildIAMBannerURLs(privateInterfaces, cfg.PrivateCertFile != "" || cfg.PrivateKeyFile != "") { + lines = append(lines, leftText(" "+u)) + } + } + } + fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") for _, line := range lines { fmt.Printf("│%-*s│\n", columnWidth-2, line) @@ -323,7 +425,7 @@ func resolveIAMBannerInterfaces(ports []string) ([]string, []string) { interfaceMap := make(map[string]bool) for _, portSpec := range ports { - if utils.IsUnixSocketPath(portSpec) { + if netutil.IsUnixSocketPath(portSpec) { allPorts = append(allPorts, portSpec) if !interfaceMap[portSpec] { interfaceMap[portSpec] = true @@ -358,7 +460,7 @@ func resolveIAMBannerInterfaces(ports []string) ([]string, []string) { func formatIAMBannerBoundHost(ports, allPorts []string) string { if len(ports) == 1 { - if utils.IsUnixSocketPath(ports[0]) { + if netutil.IsUnixSocketPath(ports[0]) { return fmt.Sprintf("(unix socket: %s)", ports[0]) } hst, prt, _ := net.SplitHostPort(ports[0]) @@ -379,7 +481,7 @@ func buildIAMBannerURLs(interfaces []string, tls bool) []string { } for _, addrPort := range interfaces { - if utils.IsUnixSocketPath(addrPort) { + if netutil.IsUnixSocketPath(addrPort) { urls = append(urls, "unix:"+addrPort) continue } diff --git a/genmtlscerts.sh b/genmtlscerts.sh new file mode 100755 index 00000000..7d9f9235 --- /dev/null +++ b/genmtlscerts.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Generate the mTLS material the S3 gateway needs to talk to a standalone IAM +# service's private endpoints over TCP: +# +# /ca.pem CA certificate, trusted by both sides +# /iam-server.pem IAM private-listener server certificate +# /iam-server.key +# /gw-client.pem S3 gateway client certificate +# /gw-client.key +# +# Usage: genmtlscerts.sh [server-ip] +# +# The server certificate carries an IP SAN for server-ip (default 127.0.0.1) +# because the gateway dials the private endpoint as "https://:" +# with standard Go certificate verification and no hostname override — an IP +# endpoint therefore needs an IP SAN, not a CN or a DNS SAN, or the handshake +# fails with a name-mismatch error. + +set -Eeuo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [server-ip]" >&2 + exit 1 +fi + +CERT_DIR="$1" +SERVER_IP="${2:-127.0.0.1}" + +mkdir -p "$CERT_DIR" + +EXT_FILE="$CERT_DIR/openssl-ext.cnf" +# Written as a file rather than passed via -addext so this works on both +# OpenSSL and the LibreSSL +cat >"$EXT_FILE" </dev/null +openssl req -new -x509 -key "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.pem" -days 1 \ + -subj "/C=US/ST=California/L=San Francisco/O=Versity/OU=Software/CN=versitygw-test-ca" + +# IAM private-listener server certificate +openssl genpkey -algorithm RSA -out "$CERT_DIR/iam-server.key" -pkeyopt rsa_keygen_bits:2048 2>/dev/null +openssl req -new -key "$CERT_DIR/iam-server.key" -out "$CERT_DIR/iam-server.csr" \ + -subj "/C=US/ST=California/L=San Francisco/O=Versity/OU=Software/CN=versitygw-iam-private" +openssl x509 -req -in "$CERT_DIR/iam-server.csr" -CA "$CERT_DIR/ca.pem" -CAkey "$CERT_DIR/ca.key" \ + -CAcreateserial -out "$CERT_DIR/iam-server.pem" -days 1 \ + -extfile "$EXT_FILE" -extensions server 2>/dev/null + +# S3 gateway client certificate. The IAM service verifies it against the CA +# but does not authorize on its identity — authorization is the root SigV4 +# credential the gateway signs each private request with. +openssl genpkey -algorithm RSA -out "$CERT_DIR/gw-client.key" -pkeyopt rsa_keygen_bits:2048 2>/dev/null +openssl req -new -key "$CERT_DIR/gw-client.key" -out "$CERT_DIR/gw-client.csr" \ + -subj "/C=US/ST=California/L=San Francisco/O=Versity/OU=Software/CN=versitygw-s3-gateway" +openssl x509 -req -in "$CERT_DIR/gw-client.csr" -CA "$CERT_DIR/ca.pem" -CAkey "$CERT_DIR/ca.key" \ + -CAcreateserial -out "$CERT_DIR/gw-client.pem" -days 1 \ + -extfile "$EXT_FILE" -extensions client 2>/dev/null + +rm -f "$CERT_DIR"/*.csr "$EXT_FILE" diff --git a/go.mod b/go.mod index f2798c2a..b698cc68 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/go-ldap/ldap/v3 v3.4.14 github.com/gofiber/fiber/v3 v3.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/vault-client-go v0.4.3 github.com/minio/crc64nvme v1.1.1 diff --git a/go.sum b/go.sum index 905340b6..894f5605 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,6 @@ github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= diff --git a/iamapi/authentication_test.go b/iamapi/authentication_test.go index a99b5ed0..3aaf2ff3 100644 --- a/iamapi/authentication_test.go +++ b/iamapi/authentication_test.go @@ -31,7 +31,6 @@ import ( "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" @@ -339,12 +338,11 @@ func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) { // TestVerifyIAMAuthRejectsExpiredQueryRequest confirms a presigned IAM // request signed too long ago is rejected by the same fixed ±15-minute -// freshness window (ValidateDateAt) header auth uses — confirmed live -// (niksis02 profile): real IAM's query-auth ignores X-Amz-Expires entirely -// (see TestVerifyIAMAuthQueryIgnoresXAmzExpires) and instead rejects a -// stale signing time with SignatureDoesNotMatch: "Signature expired: ... -// is now earlier than ... (... - 15 min.)" — byte-for-byte what this -// codebase's own SignatureDoesNotMatchExpired already produces. +// freshness window (ValidateDateAt) header auth uses: real IAM's +// query-auth ignores X-Amz-Expires entirely and instead rejects a stale +// signing time with SignatureDoesNotMatch: "Signature expired: ... is now +// earlier than ... (... - 15 min.)" — byte-for-byte what this codebase's +// own SignatureDoesNotMatchExpired already produces. func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) { app := newIAMAuthTestApp(t) signedTwoHoursAgo := time.Now().UTC().Add(-2 * time.Hour) @@ -373,10 +371,10 @@ func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) { } // TestVerifyIAMAuthQueryIgnoresXAmzExpires confirms IAM/STS query-auth -// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs -// — confirmed live (niksis02 profile) that real IAM's ListUsers accepts a -// presigned request with X-Amz-Expires omitted, non-numeric, negative, or -// far beyond S3's 604800-second maximum, every time. +// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs: +// real IAM's ListUsers accepts a presigned request with X-Amz-Expires +// omitted, non-numeric, negative, or far beyond S3's 604800-second +// maximum, every time. func TestVerifyIAMAuthQueryIgnoresXAmzExpires(t *testing.T) { for _, expires := range []string{"", "abc", "-5", "9999999"} { t.Run(expires, func(t *testing.T) { @@ -418,15 +416,21 @@ func TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned(t *testing.T) { hash := sha256.Sum256(body) payloadHash := hex.EncodeToString(hash[:]) - signer := vgwv4.NewSigner() // Sign with only "host" listed — the security-token header is present // on the wire but deliberately excluded from SignedHeaders, simulating // a client (or tampering party) that never binds it to the signature. - if _, err := signer.SignHTTP(context.Background(), - aws.Credentials{AccessKeyID: session.AccessKeyId, SecretAccessKey: session.SecretAccessKey}, - req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC(), []string{"host"}); err != nil { - t.Fatalf("sign request: %v", err) - } + signingTime := time.Now().UTC() + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(session.SecretAccessKey, yyyymmdd, iammiddleware.SigningRegion, "iam") + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = session.AccessKeyId + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, iammiddleware.SigningRegion, "iam") + in.SignedHdrs = []string{"host"} + in.PayloadHash = payloadHash + in.SigningTime = signingTime + result := sigv4auth.BuildAndSign(derivedKey, in) + req.Header.Set("X-Amz-Date", result.AmzDate) + req.Header.Set("Authorization", result.AuthorizationHeader) resp, err := server.app.Test(req) if err != nil { @@ -672,23 +676,21 @@ func querySignedIAMRequest(t *testing.T, method, target string, body []byte, sec 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) - } + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, region, "iam") + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = testRoot.Access + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, region, "iam") + in.PayloadHash = payloadHash + in.SigningTime = signingTime + in.IsPreSign = true + result := sigv4auth.BuildAndSign(derivedKey, in) - signedReq := httptest.NewRequest(method, signedURL, bytes.NewReader(body)) - for key, values := range signedHeaders { + signedURL := *req.URL + signedURL.RawQuery = result.RawQuery + + signedReq := httptest.NewRequest(method, signedURL.String(), bytes.NewReader(body)) + for key, values := range result.SignedHeaders { for _, value := range values { signedReq.Header.Add(key, value) } diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 2f12d765..39bf5156 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -171,10 +171,9 @@ func TestIAMApiControllerUserLifecycle(t *testing.T) { // TestIAMApiControllerGetRootUser confirms GetUser's self-lookup form // (UserName omitted, the only way any real AWS SDK/CLI ever invokes it, // since Query-protocol clients simply don't serialize an absent optional -// field — confirmed live: `aws iam get-user` with no --user-name, as root, -// succeeds and returns the root pseudo-user) and its non-standard explicit- -// empty-string equivalent both resolve to the actual authenticated caller — -// root, here, since doIAMAction always signs as root. +// field) and its non-standard explicit-empty-string equivalent both +// resolve to the actual authenticated caller — root, here, since +// doIAMAction always signs as root. func TestIAMApiControllerGetRootUser(t *testing.T) { server := newIAMControllerTestServer(t) @@ -2025,7 +2024,7 @@ func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) { if err != nil { t.Fatalf("storage.New: %v", err) } - server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot), WithOIDCThumbprintAutoFetchDisabled()) + server, err := New(store, testRoot, WithQuiet(), WithOIDCThumbprintAutoFetchDisabled()) if err != nil { t.Fatalf("New: %v", err) } @@ -2061,7 +2060,7 @@ func newIAMControllerTestServer(t *testing.T) *IAMApiServer { if err != nil { t.Fatalf("storage.New: %v", err) } - server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot)) + server, err := New(store, testRoot, WithQuiet()) if err != nil { t.Fatalf("New: %v", err) } @@ -2723,10 +2722,9 @@ func TestIAMApiControllerAssumeRoleWithWebIdentityMultiplePrincipalsInArray(t *t "WebIdentityToken": {token}, }) // Passes trust evaluation and the audience check; fails only at the - // network-dependent signature verification step (see the IDP - // communication error test below for that path exercised - // deterministically) — here it's enough to confirm it gets that far - // rather than being rejected as AccessDenied/InvalidIdentityToken. + // network-dependent signature verification step — here it's enough to + // confirm it gets that far rather than being rejected as + // AccessDenied/InvalidIdentityToken. requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements") } @@ -2954,8 +2952,7 @@ func TestIAMApiControllerGetCallerIdentityIncorrectServiceScope(t *testing.T) { // URL), signed with the given credentials. When sessionToken is non-empty, // the real v4 signer adds X-Amz-Security-Token to the query string itself // — the same way AWS's own SDKs presign a request for temporary -// credentials (confirmed live against real AWS: such a request, submitted -// as a plain HTTP GET with no Authorization header, succeeds). +// credentials. func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target string) *http.Request { t.Helper() @@ -2975,10 +2972,8 @@ func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target st // TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken confirms a // temporary (ASIA…) session CAN authenticate via query-string (presigned -// URL) auth when X-Amz-Security-Token matches the session — confirmed live -// against real AWS (a genuine sts.PresignClient-generated presigned -// GetCallerIdentity request, signed with real ASIA… credentials and -// submitted as a plain HTTP GET, returns 200). +// URL) auth when X-Amz-Security-Token matches the session, matching a +// genuine sts.PresignClient-generated presigned GetCallerIdentity request. func TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken(t *testing.T) { server := newIAMControllerTestServer(t) diff --git a/iamapi/internal/iammiddleware/auth.go b/iamapi/internal/iammiddleware/auth.go index c79284ee..91834588 100644 --- a/iamapi/internal/iammiddleware/auth.go +++ b/iamapi/internal/iammiddleware/auth.go @@ -14,7 +14,6 @@ package iammiddleware import ( - "context" "errors" "strconv" "time" @@ -43,10 +42,10 @@ const ( // canonical request and left unbound to the signature. // // This only applies to header auth. Query-string (presigned) auth carries -// the token as a query parameter instead, which createPresignedHTTPRequestFromCtx -// already includes in the signed canonical query string regardless of -// SignedHeaders, so requiredSignedHeaders (unconditionally "host") is used -// for both root/permanent and session query-auth requests. +// the token as a query parameter instead, which sigv4auth's presign query +// extraction already includes in the signed canonical query string +// regardless of SignedHeaders, so requiredSignedHeaders (unconditionally +// "host") is used for both root/permanent and session query-auth requests. var ( requiredSignedHeaders = []string{"host"} requiredTempSignedHeaders = []string{"host", sigv4auth.HeaderSecurityToken} @@ -67,18 +66,6 @@ type RootCredentials struct { Secret string } -// IdentityStore resolves an access key id to the session or long-term user -// that owns it, and resolves named resources for policy evaluation. -// storage.Storer satisfies this directly. -type IdentityStore interface { - GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) - GetRole(ctx context.Context, roleName string) (*types.Role, error) - GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) - GetUser(ctx context.Context, username string) (*types.User, error) - GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) - RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error -} - // VerifyIAMAuth authenticates a request against service (sigv4auth.ServiceIAM // or sigv4auth.ServiceSTS). // @@ -88,21 +75,21 @@ type IdentityStore interface { // identity (and, for a user/session, its policy documents) is stored via // httpctx.ContextKeyCallerIdentity for the policy middleware and controllers // to read back. Root bypasses the policy middleware entirely -func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) fiber.Handler { +func VerifyIAMAuth(service string, root *RootCredentials, store iamutil.IdentityStore) fiber.Handler { return func(ctx fiber.Ctx) error { authData, tdate, queryAuth, err := parseIAMAuth(ctx, service) if err != nil { return err } - // A security token in the query string is only ever legitimate - // alongside a temporary (ASIA…) access key — reject it outright for - // root or any long-term (AKIA…) credential before any signature - // work, the same way for both, rather than letting it fall through - // to a signature-mismatch error once a tampered/unsigned token - // param invalidates the canonical query string. - if queryAuth && !iamutil.IsTempAccessKeyID(authData.Access) && - ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) { + // A security token paired with root or any long-term (AKIA…) + // credential is rejected inside sigv4auth.ParseQueryAuthorization + // for query auth, and just below for header auth — before any + // signature work either way, rather than letting it fall through to + // a signature-mismatch error once a tampered/unsigned token + // invalidates the canonical request. + if !queryAuth && !sigv4auth.IsTempAccessKeyID(authData.Access) && + ctx.Get(sigv4auth.HeaderSecurityToken) != "" { return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } @@ -124,24 +111,42 @@ func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) f } httpctx.ContextKeyCallerIdentity.Set(ctx, *identity) + // Best-effort update of a permanent access key's GetAccessKeyLastUsed + // metadata, matching real IAM's behavior. A failure is only logged, + // never returned: this is purely informational, and a lost update + // under concurrent use is immaterial. Called synchronously: a Storer + // implementation for which this is network-bound (e.g. Vault) is + // expected to make it non-blocking itself. if identity.User != nil { - recordAccessKeyUsage(ctx.Context(), store, authData.Access, service) + if err := store.RecordAccessKeyUsage(ctx.Context(), authData.Access, service, SigningRegion, time.Now().UTC()); err != nil { + debuglogger.Logf("failed to record access key last-used metadata for %q: %v", authData.Access, err) + } } return nil } } -// recordAccessKeyUsage best-effort-updates a permanent access key's -// GetAccessKeyLastUsed metadata (service, region, and timestamp) after it -// successfully authenticates a request, matching real IAM's behavior. A -// failure is only logged, never returned, since this is purely -// informational metadata and a lost update under concurrent use is -// immaterial. Called synchronously: a Storer implementation for which this -// update is network-bound (e.g. Vault) is expected to make it non-blocking -// itself rather than adding that latency to every authenticated request -func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKeyID, service string) { - if err := store.RecordAccessKeyUsage(reqCtx, accessKeyID, service, SigningRegion, time.Now().UTC()); err != nil { - debuglogger.Logf("failed to record access key last-used metadata for %q: %v", accessKeyID, err) +// VerifyRootOnlySigV4 authenticates a request as strictly the configured +// root credential — used by the standalone IAM service's private +// endpoints, which only the S3 gateway itself ever calls, signing as its +// own configured IAM-client identity (root, or a dedicated IAM-access +// credential that defaults to root). Unlike VerifyIAMAuth, any +// other access key — valid IAM user, session, or unknown — is rejected +// outright before any signature work: there is no identity to resolve on +// behalf of here, and these two endpoints exist specifically so no identity +// other than the gateway's own ever needs to reach them. +func VerifyRootOnlySigV4(service string, root *RootCredentials) fiber.Handler { + return func(ctx fiber.Ctx) error { + authData, tdate, queryAuth, err := parseIAMAuth(ctx, service) + if err != nil { + return err + } + + if authData.Access != root.Access { + return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + return checkSignature(ctx, authData, root.Secret, tdate, queryAuth, service) } } @@ -154,12 +159,10 @@ func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKey // // A temporary session can be used via query-string (presigned URL) // authentication — real AWS accepts X-Amz-Security-Token as a query -// parameter for exactly this (confirmed live: a genuine presigned -// sts:GetCallerIdentity request signed with temporary/session credentials, -// carrying X-Amz-Security-Token in the query string, succeeds against real -// AWS). VerifyIAMAuth already rejects a security token paired with any -// non-temporary credential (root included) before this is ever reached. -func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { +// parameter for exactly this. VerifyIAMAuth already rejects a security +// token paired with any non-temporary credential (root included) before +// this is ever reached. +func resolveIdentity(ctx fiber.Ctx, store iamutil.IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { if store == nil { return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } @@ -167,73 +170,30 @@ func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.Auth if iamutil.IsTempAccessKeyID(authData.Access) { return resolveSessionIdentity(ctx, store, authData, queryAuth) } - return resolveUserIdentity(ctx, store, authData) -} -func resolveSessionIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { - session, err := store.GetSession(ctx.Context(), authData.Access) + identity, secret, err := iamutil.ResolveUserIdentity(ctx, store, authData.Access) if err != nil { return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } + return identity, secret, nil +} +// resolveSessionIdentity extracts the security token from wherever this +// request carries it and delegates to iamutil.ResolveSessionByToken, mapping +// its sentinel errors onto the control plane's single public-facing error — +// which deliberately does not distinguish "no such session" from "wrong +// token" for an unauthenticated caller. +func resolveSessionIdentity(ctx fiber.Ctx, store iamutil.IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { token := ctx.Get(sigv4auth.HeaderSecurityToken) if queryAuth { token = ctx.Query(sigv4auth.QuerySecurityToken) } - if token == "" || !sigv4auth.SecureCompare(token, session.SessionToken) { - return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) - } - // A signature-valid, unexpired session still authenticates even if its - // role has since been deleted — real STS credentials are self-contained - // and don't re-check role existence on every call. What such a session - // can no longer do is get any IAM action past the policy middleware: - // with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies - // by default, same effective outcome as an explicit rejection here would - // have had for every pipeline except GetCallerIdentity, which needs - // none of this and must keep working regardless. - // - // The reloaded role must also still be the *same* role the session was - // originally minted against — RoleID and Arn, both captured in the - // session at AssumeRoleWithWebIdentity time, must match the freshly - // loaded role's own values. Without this check, deleting a role and - // recreating one of the same name (necessarily getting a new RoleID) - // would let every pre-existing session for the old role silently - // inherit whatever policies the new role happens to carry. - identity := &types.Identity{ - Session: session, - SessionPolicy: session.Policy, - } - if role, err := store.GetRole(ctx.Context(), session.RoleName); err == nil && - role.RoleID == session.RoleID && role.Arn == session.RoleArn { - identity.Role = role - identity.IdentityPolicies = role.Policies.Inline - } - return identity, session.SecretAccessKey, nil -} - -func resolveUserIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData) (*types.Identity, string, error) { - user, err := store.GetUserByAccessKeyID(ctx.Context(), authData.Access) + identity, secret, err := iamutil.ResolveSessionByToken(ctx.Context(), store, authData.Access, token) if err != nil { return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } - - var keyEntry *types.AccessKeyEntry - for i := range user.AccessKeys { - if user.AccessKeys[i].AccessKeyId == authData.Access { - keyEntry = &user.AccessKeys[i] - break - } - } - if keyEntry == nil || keyEntry.Status != iamutil.AccessKeyStatusActive { - return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) - } - - identity := &types.Identity{ - User: user, - IdentityPolicies: user.Policies.Inline, - } - return identity, keyEntry.SecretAccessKey, nil + return identity, secret, nil } func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, tdate time.Time, queryAuth bool, service string) error { @@ -242,14 +202,16 @@ func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, t return err } + derivedKey := sigv4auth.DeriveKey(secret, tdate.Format(sigv4auth.YYYYMMDD), authData.Region, service) + payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw()) if queryAuth { - _, err = sigv4auth.CheckQuerySignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + _, err = sigv4auth.CheckQuerySignature(ctx, authData, derivedKey, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ Service: service, RequiredSignedHeaders: requiredSignedHeaders, }) } else { - _, err = sigv4auth.CheckSignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + _, err = sigv4auth.CheckSignature(ctx, authData, derivedKey, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ Service: service, RequiredSignedHeaders: requiredHeaderAuthSignedHeaders(authData.Access), }) @@ -311,12 +273,10 @@ func parseIAMHeaderAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthDa } // parseIAMQueryAuth parses SigV4 query-string (presigned URL) authentication -// parameters. Unlike S3 (see s3api/utils/presign-auth-reader.go), IAM/STS -// query-auth does not use X-Amz-Expires at all: confirmed live (niksis02 -// profile) against real IAM's ListUsers — a presigned request with -// X-Amz-Expires omitted, non-numeric ("abc"), negative ("-5"), or far -// beyond the 604800-second S3 maximum ("9999999") is accepted every time, -// while a request merely signed too long ago is rejected with +// parameters. Unlike S3, IAM/STS query-auth does not use X-Amz-Expires at +// all: a presigned request with X-Amz-Expires omitted, non-numeric, +// negative, or far beyond S3's 604800-second maximum is accepted every +// time, while a request merely signed too long ago is rejected with // SignatureDoesNotMatch ("Signature expired: ... is now earlier than ... // (... - 15 min.)") — byte-for-byte the same message this codebase's own // SignatureDoesNotMatchExpired already produces. So X-Amz-Expires is diff --git a/iamapi/internal/iammiddleware/errors.go b/iamapi/internal/iammiddleware/errors.go index fb8df834..41342797 100644 --- a/iamapi/internal/iammiddleware/errors.go +++ b/iamapi/internal/iammiddleware/errors.go @@ -15,6 +15,7 @@ package iammiddleware import ( "errors" + "strings" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/debuglogger" @@ -33,6 +34,13 @@ func GlobalErrorHandler(ctx fiber.Ctx, er error) error { return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID)) } + var fiberErr *fiber.Error + if errors.As(er, &fiberErr) && strings.Contains(strings.ToLower(fiberErr.Message), "cannot parse content-length") { + debuglogger.Logf("failed to parse Content-Length") + ctx.Status(fiber.StatusBadRequest) + return nil + } + if httpctx.ContextKeyStack.IsSet(ctx) { debuglogger.Panic(er) } else { diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go index 855753fb..71510693 100644 --- a/iamapi/internal/iammiddleware/policy.go +++ b/iamapi/internal/iammiddleware/policy.go @@ -15,6 +15,7 @@ package iammiddleware import ( + "maps" "strconv" "time" @@ -51,7 +52,7 @@ const iamActionPrefix = "iam:" // requestConditionContext supplies the request's aws:SourceIp/aws:username/ // aws:PrincipalArn/aws:CurrentTime/aws:EpochTime values for a statement's // Condition block. -func VerifyIAMPolicy(store IdentityStore) fiber.Handler { +func VerifyIAMPolicy(store iamutil.IdentityStore) fiber.Handler { return func(ctx fiber.Ctx) error { identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) if identity.IsRoot { @@ -68,8 +69,8 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler { Condition: requestConditionContext(ctx, identity, action, resourceTags), } - if !authorizeRequest(identity, reqCtx) { - return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + if Authorize(identity, reqCtx) != policy.DecisionAllow { + return iamerr.AccessDeniedIAMAction(CallerArn(identity), fullAction) } // A rename/path-move is a two-resource transition: AWS's UpdateUser @@ -79,8 +80,8 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler { if target := updateUserTargetResource(ctx, store); target != "" { targetCtx := reqCtx targetCtx.Resource = target - if !authorizeRequest(identity, targetCtx) { - return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + if Authorize(identity, targetCtx) != policy.DecisionAllow { + return iamerr.AccessDeniedIAMAction(CallerArn(identity), fullAction) } } } @@ -89,20 +90,58 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler { } } -// authorizeRequest reports whether reqCtx is allowed by identity's own -// inline policies and, for a session with a session policy attached, the -// narrowing session policy as well. -func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) bool { - if !policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx) { - return false +// Authorize reports how identity's own inline policies and, for a session +// with a session policy attached, the narrowing session policy as well, +// decide reqCtx. +// +// A session policy can only narrow, never widen, what the role's identity +// policies otherwise allow — matching AWS's permission-boundary semantics +// for AssumeRole session policies — so this is an intersection, not the +// "either source is independently sufficient" combination VerifyAccess uses +// for S3 bucket-policy-vs-identity-policy: an explicit Deny from either +// layer here always wins outright, and the result is DecisionAllow only +// when both layers (or just the identity layer, absent a session policy) +// independently reach DecisionAllow. +func Authorize(identity types.Identity, reqCtx policy.RequestContext) policy.Decision { + d, sd, hasSessionPolicy := AuthorizeSplit(identity, reqCtx) + if d == policy.DecisionDeny { + return policy.DecisionDeny } - if identity.Session != nil && identity.SessionPolicy != "" { - sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}} - if !policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx) { - return false - } + if !hasSessionPolicy { + return d } - return true + if sd == policy.DecisionDeny { + return policy.DecisionDeny + } + if d != policy.DecisionAllow || sd != policy.DecisionAllow { + return policy.DecisionNoMatch + } + return policy.DecisionAllow +} + +// AuthorizeSplit reports the identity-policy and session-policy decisions +// separately, rather than folded together as Authorize does, plus whether a +// session policy applied at all. +// +// The two must stay separable for the S3 data plane, where a *resource* +// policy is also in play. A session policy filters everything, including +// permissions that came from the bucket policy rather than from the role +// with a role carrying no identity policy at all, a bucket policy granting +// s3:GetObject and s3:PutObject to that role, and a session policy allowing only +// s3:GetObject, the Get succeeds and the Put is denied. Collapsing the two into +// one decision here would lose the distinction between "the session policy did +// not permit this" (which must deny even against a bucket-policy Allow) and +// "the role's own policies did not permit this" (which a bucket-policy +// Allow may still grant). +func AuthorizeSplit(identity types.Identity, reqCtx policy.RequestContext) (identityDecision, sessionDecision policy.Decision, hasSessionPolicy bool) { + identityDecision = policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx) + + if identity.Session == nil || identity.SessionPolicy == "" { + return identityDecision, policy.DecisionNoMatch, false + } + + sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}} + return identityDecision, policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx), true } // resourceForAction resolves the ARN action targets and, when that ARN names @@ -127,7 +166,7 @@ func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) boo // request still reaches the controller afterward, which reports the // specific NoSuchEntity/MissingValue error if authorization happens to pass // on a wildcard grant, or AccessDenied first if it doesn't. -func resourceForAction(ctx fiber.Ctx, store IdentityStore, action string) (string, []types.Tag) { +func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string) (string, []types.Tag) { switch action { case "CreateUser": return newUserResource(ctx), nil @@ -177,7 +216,7 @@ func newUserResource(ctx fiber.Ctx) string { // used elsewhere — none of this group's actions actually accept an omitted // UserName (the controller layer requires it), so this only guards against // a malformed request reaching here. -func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { +func existingUserResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) { userName, ok := iamutil.RequestParam(ctx, "UserName") if !ok || userName == "" { return "", nil @@ -195,7 +234,7 @@ func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.T // own Arn and Tags. A session (assumed role) has no self IAM user to // resolve, so it falls back to ("", nil), the same lookup-failure fallback // used elsewhere. -func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { +func getUserResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) { userName, ok := iamutil.RequestParam(ctx, "UserName") if !ok || userName == "" { identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) @@ -216,7 +255,7 @@ func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { // AccessKeyId being queried, so the resource-level check is against the IAM // user that owns that key, matching real IAM's resource-type classification // for this action. -func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { +func accessKeyOwnerResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) { accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") if !ok || accessKeyID == "" { return "", nil @@ -235,7 +274,7 @@ func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types // "" when the request doesn't actually relocate the user (neither NewPath // nor NewUserName supplied) or when the source user can't be resolved, the // same fallback used elsewhere when a lookup fails. -func updateUserTargetResource(ctx fiber.Ctx, store IdentityStore) string { +func updateUserTargetResource(ctx fiber.Ctx, store iamutil.IdentityStore) string { newPath, _ := iamutil.RequestParam(ctx, "NewPath") newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") if newPath == "" && newUserName == "" { @@ -272,7 +311,7 @@ func newRoleResource(ctx fiber.Ctx) string { return iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName) } -func existingRoleResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { +func existingRoleResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) { roleName, ok := iamutil.RequestParam(ctx, "RoleName") if !ok || roleName == "" { return "*", nil @@ -333,21 +372,7 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri if ip := ctx.IP(); ip != "" { condCtx["aws:SourceIp"] = []string{ip} } - if arn := callerArn(identity); arn != "" { - condCtx["aws:PrincipalArn"] = []string{arn} - condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID} - } - switch { - case identity.User != nil: - condCtx["aws:username"] = []string{identity.User.UserName} - condCtx["aws:userid"] = []string{identity.User.UserID} - addPrincipalTagContext(condCtx, identity.User.Tags) - case identity.Session != nil: - condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName} - if identity.Role != nil { - addPrincipalTagContext(condCtx, identity.Role.Tags) - } - } + maps.Copy(condCtx, IdentityConditionContext(identity)) for _, tag := range resourceTags { condCtx["iam:ResourceTag/"+tag.Key] = []string{tag.Value} @@ -362,6 +387,59 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri return condCtx } +// IdentityConditionContext builds the condition keys that describe *who* is +// calling — as opposed to the request-derived keys (time, source IP, +// transport) that its callers add around it. +// +// Splitting these out is what lets the standalone-IAM private +// evaluate-policy endpoint serve the S3 gateway: the gateway knows the +// request but not the identity behind the access key, so it sends only the +// request-derived keys and this side fills in the rest from the identity it +// resolved. The gateway is never trusted to supply these keys itself, even +// though it authenticates as root. +func IdentityConditionContext(identity types.Identity) map[string][]string { + condCtx := map[string][]string{} + if arn := CallerArn(identity); arn != "" { + condCtx["aws:PrincipalArn"] = []string{arn} + condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID} + } + switch { + case identity.User != nil: + condCtx["aws:PrincipalType"] = []string{"User"} + condCtx["aws:username"] = []string{identity.User.UserName} + condCtx["aws:userid"] = []string{identity.User.UserID} + addPrincipalTagContext(condCtx, identity.User.Tags) + case identity.Session != nil: + condCtx["aws:PrincipalType"] = []string{"AssumedRole"} + condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName} + if identity.Role != nil { + addPrincipalTagContext(condCtx, identity.Role.Tags) + } + } + return condCtx +} + +// IdentityConditionKeyPrefixes lists the condition-key namespaces that +// describe the caller or the resource, and that therefore only the IAM +// service may populate. handleEvaluatePolicy strips every one of them from +// a gateway-supplied context before overlaying its own — an +// override-on-collision merge would leave any key the service happens *not* +// to set (aws:PrincipalTag/x for an untagged role, say) under the +// gateway's control, which is exactly what a StringNotEquals-guarded Allow +// keys off. +var IdentityConditionKeyPrefixes = []string{ + "aws:PrincipalArn", + "aws:PrincipalAccount", + "aws:PrincipalType", + "aws:username", + "aws:userid", + "aws:PrincipalTag/", + "aws:ResourceTag/", + "iam:ResourceTag/", + "aws:RequestTag/", + "aws:TagKeys", +} + // addPrincipalTagContext populates aws:PrincipalTag/ from tags, the // calling principal's own tags. func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) { @@ -390,9 +468,9 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { condCtx["aws:TagKeys"] = keys } -// callerArn identifies identity the way real IAM error messages do: the +// CallerArn identifies identity the way real IAM error messages do: the // user's own Arn, or the assumed-role session Arn. -func callerArn(identity types.Identity) string { +func CallerArn(identity types.Identity) string { if identity.Session != nil { return iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, identity.Session.RoleName, identity.Session.RoleSessionName) } diff --git a/iamapi/internal/iamutil/access_key.go b/iamapi/internal/iamutil/access_key.go index a60320db..9458a837 100644 --- a/iamapi/internal/iamutil/access_key.go +++ b/iamapi/internal/iamutil/access_key.go @@ -18,10 +18,10 @@ import ( "crypto/rand" "encoding/base64" "regexp" - "strings" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/internal/sigv4auth" ) const ( @@ -34,11 +34,7 @@ const ( maxAccessKeyIDLen = 128 secretAccessKeyBytes = 30 - // tempAccessKeyIDPrefix marks temporary credentials minted by - // AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that - // distinguishes them from long-term AKIA… access keys. - tempAccessKeyIDPrefix = "ASIA" - sessionTokenBytes = 128 + sessionTokenBytes = 128 ) var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`) @@ -69,7 +65,7 @@ func GenerateSecretAccessKey() (string, error) { // access key id in the ASIA… format, for credentials minted by // AssumeRoleWithWebIdentity. func GenerateTempAccessKeyID() (string, error) { - id, err := generateAWSID(tempAccessKeyIDPrefix, accessKeyIDRandomLen) + id, err := generateAWSID(sigv4auth.TempAccessKeyIDPrefix, accessKeyIDRandomLen) if err != nil { debuglogger.Logf("failed to generate temporary IAM access key id: %v", err) return "", err @@ -93,9 +89,10 @@ func GenerateSessionToken() (string, error) { // IsTempAccessKeyID reports whether accessKeyID has the ASIA… prefix used // for temporary credentials minted by AssumeRoleWithWebIdentity, as opposed -// to a long-term AKIA… access key. +// to a long-term AKIA… access key. It delegates to sigv4auth so the S3 +// gateway, which cannot import this package, shares one definition. func IsTempAccessKeyID(accessKeyID string) bool { - return strings.HasPrefix(accessKeyID, tempAccessKeyIDPrefix) + return sigv4auth.IsTempAccessKeyID(accessKeyID) } // ValidateAccessKeyID checks that accessKeyID fits within the allowed length diff --git a/iamapi/internal/iamutil/identity.go b/iamapi/internal/iamutil/identity.go new file mode 100644 index 00000000..b5ac8275 --- /dev/null +++ b/iamapi/internal/iamutil/identity.go @@ -0,0 +1,145 @@ +// 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 ( + "context" + "errors" + "time" + + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" +) + +// ErrIdentityNotFound and ErrInvalidSessionToken are returned by +// ResolveSessionByToken and ResolveUserIdentity, so a caller that needs to +// know *which* failure occurred — the standalone-IAM private endpoints, and +// iammiddleware's own sigv4 pipeline, which turn them into S3's own +// InvalidAccessKeyId and InvalidToken respectively — can distinguish them. +// +// The public IAM control plane deliberately collapses both into a single +// InvalidClientTokenId: an unauthenticated caller must not learn whether an +// access key exists. +var ( + ErrIdentityNotFound = errors.New("identity not found") + ErrInvalidSessionToken = errors.New("invalid session token") +) + +// IdentityStore resolves an access key id to the session or long-term user +// that owns it, and resolves named resources for policy evaluation. +// storage.Storer satisfies this directly. +type IdentityStore interface { + GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) + GetUser(ctx context.Context, username string) (*types.User, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error +} + +// ResolveSessionByToken resolves a temporary (ASIA…) access key to the +// session that owns it, requiring token to match the session's stored +// SessionToken. The empty-token rejection and the constant-time comparison +// both live here rather than in any caller: this is the only function that +// may turn a session access key id into a secret, so no caller can be +// written that skips them. +// +// It does not itself verify a SigV4 signature — request-pipeline callers do +// that next, so a stolen or guessed access key id plus token is never +// sufficient on its own. +func ResolveSessionByToken(ctx context.Context, store IdentityStore, accessKeyID, token string) (*types.Identity, string, error) { + // No token means there is nothing to resolve the access key against, so + // the key is reported as simply not existing rather than as a bad token + // — matching real S3, which answers InvalidAccessKeyId for a temporary + // access key presented with no X-Amz-Security-Token, and InvalidToken + // only once a token is actually present and wrong. + if token == "" { + return nil, "", ErrIdentityNotFound + } + + session, err := store.GetSession(ctx, accessKeyID) + if err != nil { + return nil, "", ErrIdentityNotFound + } + + if !sigv4auth.SecureCompare(token, session.SessionToken) { + return nil, "", ErrInvalidSessionToken + } + + // A signature-valid, unexpired session still authenticates even if its + // role has since been deleted — real STS credentials are self-contained + // and don't re-check role existence on every call. What such a session + // can no longer do is get any IAM action past the policy middleware: + // with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies + // by default, same effective outcome as an explicit rejection here would + // have had for every pipeline except GetCallerIdentity, which needs + // none of this and must keep working regardless. + // + // The reloaded role must also still be the *same* role the session was + // originally minted against — RoleID and Arn, both captured in the + // session at AssumeRoleWithWebIdentity time, must match the freshly + // loaded role's own values. Without this check, deleting a role and + // recreating one of the same name (necessarily getting a new RoleID) + // would let every pre-existing session for the old role silently + // inherit whatever policies the new role happens to carry. + identity := &types.Identity{ + Session: session, + SessionPolicy: session.Policy, + } + if role, err := store.GetRole(ctx, session.RoleName); err == nil && + role.RoleID == session.RoleID && role.Arn == session.RoleArn { + identity.Role = role + identity.IdentityPolicies = role.Policies.Inline + } + return identity, session.SecretAccessKey, nil +} + +// ResolveUserIdentity resolves accessKeyID to its long-term (AKIA…) IAM user +// and secret, reporting the ErrIdentityNotFound/ErrInvalidSessionToken +// sentinels rather than an opaque API error — callers on the public control +// plane that want the opaque error do that translation themselves. +// +// Temporary (ASIA…) session access keys are rejected here: resolving one +// safely requires validating its security token, which this function has no +// parameter for. A caller that can supply a token uses ResolveSessionByToken +// instead. Silently resolving a session's secret from its access key id +// alone, with no token check at all, would let anyone who merely knows the +// id impersonate the session. +func ResolveUserIdentity(ctx context.Context, store IdentityStore, accessKeyID string) (*types.Identity, string, error) { + if sigv4auth.IsTempAccessKeyID(accessKeyID) { + return nil, "", ErrInvalidSessionToken + } + + user, err := store.GetUserByAccessKeyID(ctx, accessKeyID) + if err != nil { + return nil, "", ErrIdentityNotFound + } + + var keyEntry *types.AccessKeyEntry + for i := range user.AccessKeys { + if user.AccessKeys[i].AccessKeyId == accessKeyID { + keyEntry = &user.AccessKeys[i] + break + } + } + if keyEntry == nil || keyEntry.Status != AccessKeyStatusActive { + return nil, "", ErrIdentityNotFound + } + + identity := &types.Identity{ + User: user, + IdentityPolicies: user.Policies.Inline, + } + return identity, keyEntry.SecretAccessKey, nil +} diff --git a/iamapi/internal/iamutil/webidentity.go b/iamapi/internal/iamutil/webidentity.go index 9be96f66..61782ac1 100644 --- a/iamapi/internal/iamutil/webidentity.go +++ b/iamapi/internal/iamutil/webidentity.go @@ -382,11 +382,11 @@ func VerifyWebIdentityExpiration(claims jwt.MapClaims, now time.Time) error { // web identity token claims beyond exp (already checked separately by // VerifyWebIdentityExpiration): iat and sub must both be present, and nbf // (if present) must not be in the future beyond webIdentityExpLeeway of -// clock skew. Confirmed against real AWS (niksis02 profile): a token with -// exp but no iat, or with iat but no sub, is rejected with -// InvalidIdentityToken "Missing a required claim: ." — without -// this check, such a token would otherwise obtain credentials whenever the -// role's trust policy doesn't itself require sub via Condition. +// clock skew. A token with exp but no iat, or with iat but no sub, is +// rejected with InvalidIdentityToken "Missing a required claim: +// ." — without this check, such a token would otherwise obtain +// credentials whenever the role's trust policy doesn't itself require sub +// via Condition. func VerifyWebIdentityRequiredClaims(claims jwt.MapClaims, now time.Time) error { if _, ok := claims["iat"].(float64); !ok { debuglogger.Logf("web identity token has no iat claim") @@ -580,8 +580,8 @@ type jwksCacheEntry struct { keys *jwkSet expiresAt time.Time // lastForcedRefresh is when an unknown-kid lookup last bypassed - // expiresAt to force a fetch for this issuer, gating - // jwksMinForcedRefreshInterval (see forceRefreshJWKSCache). + // expiresAt to force a fetch for this issuer, gated by + // jwksMinForcedRefreshInterval. lastForcedRefresh time.Time } diff --git a/iamapi/policy/identity.go b/iamapi/policy/identity.go index 4ee29dab..a4696fae 100644 --- a/iamapi/policy/identity.go +++ b/iamapi/policy/identity.go @@ -19,6 +19,7 @@ import ( "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/condition" ) // MaxSessionPolicyBytes is the maximum length, in bytes, of the optional @@ -45,31 +46,53 @@ type RequestContext struct { Condition map[string][]string } -// EvaluateIdentityPolicies reports whether reqCtx is allowed by documents -// (each a user's or role's inline policy entry), using IAM's evaluation -// semantics: a statement must cover the action, the resource, and (if -// present) its Condition block to be considered at all; an explicit Deny -// statement that does so makes the whole evaluation deny regardless of any -// Allow found elsewhere (in the same or another document), and absent an -// explicit deny, at least one covering Allow statement is required — so an -// identity with no matching statement at all is denied by default. +// Decision is the tri-state result of evaluating a set of identity policy +// documents. A caller combining this with another policy source (e.g. an S3 +// bucket policy) needs this distinction, not a plain bool, to implement +// AWS's real cross-policy precedence: an explicit Deny from either source +// wins outright over an Allow from the other, but a NoMatch from one source +// leaves the other free to grant access on its own. +type Decision int + +const ( + // DecisionNoMatch means no statement in any document matched reqCtx at + // all — neither an Allow nor a Deny. + DecisionNoMatch Decision = iota + // DecisionAllow means at least one statement matched with Effect Allow, + // and no statement matched with Effect Deny. + DecisionAllow + // DecisionDeny means a statement matched with Effect Deny, or the + // evaluation failed closed (unparseable/invalid document, or a + // Condition that couldn't be evaluated). + DecisionDeny +) + +// EvaluateIdentityPolicies reports how documents (each a user's or role's +// inline policy entry) decide reqCtx, using IAM's evaluation semantics: a +// statement must cover the action, the resource, and (if present) its +// Condition block to be considered at all; a matching explicit Deny +// statement makes the whole evaluation DecisionDeny regardless of any Allow +// found elsewhere (in the same or another document); absent an explicit +// deny, at least one covering Allow statement is required for DecisionAllow +// — an identity with no matching statement at all gets DecisionNoMatch, not +// DecisionAllow. // -// A document that fails to parse, or a statement whose Condition block can't -// be evaluated (see evaluateCondition's ok return), denies the whole -// evaluation rather than being skipped: PutUserPolicy/PutRolePolicy already -// reject any policy document that wouldn't parse or whose Condition uses an +// A document that fails to parse, or a statement whose Condition block +// can't be evaluated, returns DecisionDeny +// rather than being skipped: PutUserPolicy/PutRolePolicy already reject any +// policy document that wouldn't parse or whose Condition uses an // unrecognized operator, so this only matters for documents written before // that validation existed - and for exactly that legacy-data case, we can't // rule out a hidden Deny inside the part we can't evaluate, so the safe // outcome is to deny rather than silently proceed as if it wasn't there. -func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) bool { +func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) Decision { allowed := false for _, entry := range documents { var doc Document if err := json.Unmarshal([]byte(entry.PolicyDocument), &doc); err != nil { debuglogger.Logf("identity policy document failed to parse: %v", err) - return false + return DecisionDeny } // PutUserPolicy/PutRolePolicy already reject a document that // wouldn't pass Validate (e.g. both Action and NotAction on one @@ -81,7 +104,7 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte // not just at ingress. if err := doc.Validate(); err != nil { debuglogger.Logf("identity policy document failed validation: %v", err) - return false + return DecisionDeny } for _, stmt := range doc.Statement { @@ -94,10 +117,10 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte if !statementCoversResource(stmt, reqCtx.Resource, reqCtx.Condition, doc.Version) { continue } - matched, ok := evaluateCondition(stmt.Condition, reqCtx.Condition, doc.Version) + matched, ok := condition.Evaluate(stmt.Condition, reqCtx.Condition, doc.Version) if !ok { debuglogger.Logf("identity policy evaluation: statement condition could not be evaluated, denying") - return false + return DecisionDeny } if !matched { continue @@ -105,13 +128,16 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte if stmt.Effect == "Deny" { debuglogger.Logf("identity policy evaluation: action %q on resource %q explicitly denied", reqCtx.Action, reqCtx.Resource) - return false + return DecisionDeny } allowed = true } } - return allowed + if allowed { + return DecisionAllow + } + return DecisionNoMatch } // statementCoversResource reports whether stmt's Resource/NotResource @@ -140,9 +166,9 @@ func matchAnyResource(patterns []string, resource string, ctxVars map[string][]s for _, p := range patterns { pattern := p if version == Version2012 { - pattern = substitutePolicyVariables(p, ctxVars) + pattern = condition.SubstitutePolicyVariables(p, ctxVars) } - if globMatch(pattern, resource) { + if condition.GlobMatch(pattern, resource) { return true } } diff --git a/iamapi/policy/identity_test.go b/iamapi/policy/identity_test.go index c6b1ff58..0580d722 100644 --- a/iamapi/policy/identity_test.go +++ b/iamapi/policy/identity_test.go @@ -33,31 +33,31 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name string documents []types.PolicyEntry reqCtx RequestContext - want bool + want Decision }{ { name: "no documents denies by default", documents: nil, reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionNoMatch, }, { name: "no matching statement denies by default", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionNoMatch, }, { name: "matching allow statement allows", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: true, + want: DecisionAllow, }, { name: "wildcard action allows", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: true, + want: DecisionAllow, }, { name: "explicit deny overrides an allow in another document", @@ -66,19 +66,19 @@ func TestEvaluateIdentityPolicies(t *testing.T) { `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`, ), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionDeny, }, { name: "explicit deny overrides an allow in the same document", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionDeny, }, { name: "action match is case-insensitive", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"IAM:CREATEUSER","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: true, + want: DecisionAllow, }, { // A malformed document might have contained a Deny we can no @@ -87,13 +87,13 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name: "malformed document denies the whole evaluation, even with a valid Allow elsewhere", documents: policyEntries(`not json`, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionDeny, }, { name: "malformed document denies the whole evaluation regardless of document order", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`, `not json`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionDeny, }, { // A Deny guarded by a Condition operator this package doesn't @@ -106,7 +106,7 @@ func TestEvaluateIdentityPolicies(t *testing.T) { `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, ), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionDeny, }, { // Fail-closed on a condition-evaluation error isn't scoped to @@ -115,7 +115,7 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name: "unrecognized operator on an Allow-only statement still denies (fail-closed is not Deny-specific)", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionDeny, }, { // A document containing any statement Validate() would @@ -128,73 +128,73 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name: "unrecognized operator in an unrelated statement invalidates the whole document", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:DeleteUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionDeny, }, { name: "Null operator end-to-end: denies presence of aws:username", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionDeny, }, { name: "Null operator end-to-end: allows when aws:username is absent (session, not user)", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:userid": {"role-id:session"}}}, - want: true, + want: DecisionAllow, }, { name: "NotAction denies coverage for the excluded action", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, - want: false, + want: DecisionNoMatch, }, { name: "NotAction allows actions outside the exclusion", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), reqCtx: RequestContext{Action: "iam:DeleteUser", Resource: "*"}, - want: true, + want: DecisionAllow, }, { name: "resource-scoped allow matches the named resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, - want: true, + want: DecisionAllow, }, { name: "resource-scoped allow does not cover a different resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, - want: false, + want: DecisionNoMatch, }, { name: "resource match is case-sensitive", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/Role-A"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, - want: false, + want: DecisionNoMatch, }, { name: "resource-scoped deny only affects the named resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, - want: true, + want: DecisionAllow, }, { name: "resource-scoped deny denies the named resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, - want: false, + want: DecisionDeny, }, { name: "NotResource excludes the named resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, - want: false, + want: DecisionNoMatch, }, { name: "NotResource allows resources outside the exclusion", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, - want: true, + want: DecisionAllow, }, { // ${aws:username} in Resource must resolve to the requesting @@ -203,19 +203,19 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name: "policy variable in Resource matches the caller's own resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: true, + want: DecisionAllow, }, { name: "policy variable in Resource does not match a different principal's resource", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/bob", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionNoMatch, }, { name: "unresolvable policy variable in Resource is left literal and so does not match a real ARN", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice"}, - want: false, + want: DecisionNoMatch, }, { // AWS requires Version 2012-10-17 to use policy variables at @@ -224,37 +224,37 @@ func TestEvaluateIdentityPolicies(t *testing.T) { name: "policy variable in Resource is not substituted under version 2008-10-17", documents: policyEntries(`{"Version":"2008-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionNoMatch, }, { name: "policy variable in Resource is not substituted with no Version at all", documents: policyEntries(`{"Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: false, + want: DecisionNoMatch, }, { name: "condition must match", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, - want: true, + want: DecisionAllow, }, { name: "condition mismatch denies by default", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"bob"}}}, - want: false, + want: DecisionNoMatch, }, { name: "deny condition must also match to take effect", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}}, - want: true, + want: DecisionAllow, }, { name: "deny condition matching denies", documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}}, - want: false, + want: DecisionDeny, }, } diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go index 9c09a737..7ce1832b 100644 --- a/iamapi/policy/trust.go +++ b/iamapi/policy/trust.go @@ -20,12 +20,13 @@ import ( "strings" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/internal/condition" ) // trustPrincipalKeys are the only keys IAM accepts inside a trust policy // statement's Principal object. CanonicalUser is deliberately not accepted -// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical -// user id which is the legacy s3 user identifier and is not planned to support +// since it identifies an S3 canonical user id, the legacy S3 user +// identifier, which is not planned to be supported here. var trustPrincipalKeys = map[string]bool{ "AWS": true, "Service": true, @@ -175,7 +176,7 @@ func (d Document) ValidateTrust() error { // valid Effect, a required Principal (never NotPrincipal), an Action or // NotAction with only "sts:"-prefixed values, no Resource/NotResource, and - // if present - a Condition block whose operators are all recognized (see -// conditionShapeValid, shared with the identity-policy side; condition +// condition.ShapeValid, shared with the identity-policy side; condition // *keys* and operand *values* are deliberately not validated here, matching // AWS behavior). func (s Statement) ValidateTrust() error { @@ -197,7 +198,7 @@ func (s Statement) ValidateTrust() error { return err } - if !conditionShapeValid(s.Condition) { + if !condition.ShapeValid(s.Condition) { return errTrustSyntax } @@ -324,9 +325,9 @@ func validateSharedProviderTenancy(s Statement, federated []string) error { } // oidcProviderURLFromFederatedArn extracts the provider Url from a Federated -// principal ARN shaped like "arn:aws:iam:::oidc-provider/" -// (see iamutil.BuildOIDCProviderArn), reporting ok=false for any value not -// shaped like an OIDC provider ARN at all — a bare federation identifier +// principal ARN shaped like "arn:aws:iam:::oidc-provider/", +// reporting ok=false for any value not shaped like an OIDC provider ARN at +// all — a bare federation identifier // (e.g. "cognito-identity.amazonaws.com") or a malformed value, both handled // elsewhere (this is deliberately a lightweight shape check, not full ARN // validation: an actually-malformed ARN is caught later, when the runtime @@ -356,23 +357,23 @@ func oidcProviderURLFromFederatedArn(value string) (string, bool) { // and StringEqualsIgnoreCase don't treat '*'/'?' as wildcards at all, so // only the plain "empty or exactly '*'" check applies to them. A block that // fails to parse reports false, same as an absent one — -// conditionShapeValid/evaluateCondition are responsible for rejecting or +// condition.ShapeValid/condition.Evaluate are responsible for rejecting or // fail-closing a block this can't understand; this check only ever adds a // stricter write-time requirement on top of that. func conditionScopesClaim(raw json.RawMessage, key string) bool { if len(raw) == 0 { return false } - var block map[string]map[string]ConditionValues - if err := json.Unmarshal(raw, &block); err != nil { + block, err := condition.Parse(raw) + if err != nil { return false } for operator, kvs := range block { - op, ok := parseOperatorName(operator) + op, ok := condition.ParseOperatorName(operator) if !ok { continue } - switch op.base { + switch op.Base { case "StringEquals", "StringLike", "StringEqualsIgnoreCase": default: continue @@ -385,7 +386,7 @@ func conditionScopesClaim(raw json.RawMessage, key string) bool { if v == "" || v == "*" { continue } - if op.base == "StringLike" && !hasNonWildcardCharacter(v) { + if op.Base == "StringLike" && !hasNonWildcardCharacter(v) { continue } return true diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go index 3987897b..d4a8c74f 100644 --- a/iamapi/policy/validate.go +++ b/iamapi/policy/validate.go @@ -21,6 +21,7 @@ import ( "strings" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/internal/condition" ) // MaxDocumentLength is IAM's parameter-level maximum length for a @@ -122,8 +123,8 @@ func (d Document) Validate() error { // no Principal/NotPrincipal, an Action or NotAction (not both) with // vendor-prefixed values, a Resource or NotResource (not both) with // ARN-shaped values, and - if present - a Condition block whose operators -// are all recognized (see conditionShapeValid; condition *keys* and operand -// *values* are deliberately not validated here, matching AWS behavior). +// are all recognized (condition *keys* and operand *values* are +// deliberately not validated here, matching AWS behavior). func (s Statement) Validate() error { switch s.Effect { case "Allow", "Deny": @@ -135,7 +136,7 @@ func (s Statement) Validate() error { return errPrincipalNotAllowed } - if !conditionShapeValid(s.Condition) { + if !condition.ShapeValid(s.Condition) { return errSyntax } diff --git a/iamapi/policy/webidentity.go b/iamapi/policy/webidentity.go index 954acb93..23afd157 100644 --- a/iamapi/policy/webidentity.go +++ b/iamapi/policy/webidentity.go @@ -20,6 +20,7 @@ import ( "time" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/condition" ) // AssumeRoleWithWebIdentityAction is the sts action name role trust @@ -182,7 +183,7 @@ func EvaluateWebIdentityTrust(document string, lookup ProviderLookup, wctx WebId } anyIssuerMatch = true - matched, condOk := evaluateCondition(stmt.Condition, ctxVars, doc.Version) + matched, condOk := condition.Evaluate(stmt.Condition, ctxVars, doc.Version) if !condOk { debuglogger.Logf("web identity trust evaluation: statement condition could not be evaluated, denying") denied = true @@ -260,7 +261,7 @@ func matchAny(patterns []string, action string) bool { // IAM-style glob ('*' any run of characters, '?' any single character) — // e.g. "sts:*" or "sts:AssumeRole*" both match "sts:AssumeRoleWithWebIdentity". func matchActionPattern(pattern, action string) bool { - return globMatch(toLowerASCII(pattern), toLowerASCII(action)) + return condition.GlobMatch(toLowerASCII(pattern), toLowerASCII(action)) } func toLowerASCII(s string) string { @@ -272,32 +273,3 @@ func toLowerASCII(s string) string { } return string(b) } - -// globMatch implements the small wildcard grammar IAM Action/Resource -// patterns use: '*' matches any run of characters (including none), '?' -// matches exactly one character, everything else matches literally. -func globMatch(pattern, s string) bool { - var pi, si, star, match int - star = -1 - for si < len(s) { - switch { - case pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == s[si]): - pi++ - si++ - case pi < len(pattern) && pattern[pi] == '*': - star = pi - match = si - pi++ - case star != -1: - pi = star + 1 - match++ - si = match - default: - return false - } - } - for pi < len(pattern) && pattern[pi] == '*' { - pi++ - } - return pi == len(pattern) -} diff --git a/iamapi/policy/webidentity_test.go b/iamapi/policy/webidentity_test.go index 46aa2290..f2c96493 100644 --- a/iamapi/policy/webidentity_test.go +++ b/iamapi/policy/webidentity_test.go @@ -161,7 +161,7 @@ func TestEvaluateWebIdentityTrust(t *testing.T) { // RequestContext.Condition on the identity-policy side - this // is the most realistic place to exercise the multivalue // aggregation semantics documented on aggregate() in - // condition.go. "banned" is present among the claim's values, + // internal/condition. "banned" is present among the claim's values, // so unqualified StringNotEquals (pre-existing, unchanged // semantics: fails to match if any actual value matches) fails // to match, and the Allow's condition doesn't hold. diff --git a/iamapi/private/errors.go b/iamapi/private/errors.go new file mode 100644 index 00000000..1cceb2c8 --- /dev/null +++ b/iamapi/private/errors.go @@ -0,0 +1,119 @@ +// 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 private + +import ( + "errors" + "net/http" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/internal/iamutil" +) + +// Error codes carried in the JSON error body's "code" field. The S3 +// gateway maps them to distinct S3 errors — CodeNoSuchIdentity to +// InvalidAccessKeyId, CodeInvalidToken to InvalidToken — so an end user +// gets an accurate diagnosis instead of one catch-all. Without them every +// 403 looks identical on the wire, and a gateway whose own IAM-client +// credential was rotated would tell the *user* their access key doesn't +// exist. +const ( + CodeNoSuchIdentity = "NoSuchIdentity" + CodeInvalidToken = "InvalidToken" + CodeBadRequest = "BadRequest" +) + +// privateAPIError is a minimal local error for failures (like a malformed +// request body) that don't map to any of iamerr's AWS-IAM-specific error +// codes — this protocol is plain JSON, not the rest of iamapi's +// AWS-Query/XML wire format, so there's no need to force every error +// through iamerr.APIError's XML-rendering machinery. +type privateAPIError struct { + status int + code string + message string +} + +func (e *privateAPIError) Error() string { return e.message } +func (e *privateAPIError) StatusCode() int { return e.status } +func (e *privateAPIError) Code() string { return e.code } + +var ( + errMalformedRequestBody = &privateAPIError{ + status: http.StatusBadRequest, + code: CodeBadRequest, + message: "malformed request body", + } + errNoSuchIdentity = &privateAPIError{ + status: http.StatusForbidden, + code: CodeNoSuchIdentity, + message: "no identity for the given access key id", + } + errInvalidSessionToken = &privateAPIError{ + status: http.StatusForbidden, + code: CodeInvalidToken, + message: "the given session token is missing, invalid, or does not belong to the given access key id", + } +) + +// mapResolveError translates iamutil's identity-resolution sentinels into +// the wire errors this protocol reports. Anything unrecognized falls through +// unchanged and renders as a 500, which is the correct signal: it is a fault +// in the IAM service, not a problem with the caller's identity. +func mapResolveError(err error) error { + switch { + case errors.Is(err, iamutil.ErrIdentityNotFound): + return errNoSuchIdentity + case errors.Is(err, iamutil.ErrInvalidSessionToken): + return errInvalidSessionToken + default: + return err + } +} + +// coder is implemented by errors carrying a stable machine-readable code +// for the "code" field of the JSON error body. +type coder interface { + Code() string +} + +// statusCoder is satisfied by both iamerr.APIError (used by +// iammiddleware.VerifyRootOnlySigV4) and privateAPIError, so errorHandler +// can extract the right HTTP status from either without depending on +// iamerr's XML-specific interface methods. +type statusCoder interface { + StatusCode() int +} + +// errorHandler renders any error as a small JSON body with the matching +// HTTP status (defaulting to 500 for an error with no known status) and, +// where the error carries one, a machine-readable code the S3 gateway +// dispatches on. +func (p *PrivateAPI) errorHandler(ctx fiber.Ctx, err error) error { + status := http.StatusInternalServerError + if sc, ok := err.(statusCoder); ok { + status = sc.StatusCode() + } else { + debuglogger.InternalError(err) + } + + body := map[string]string{"error": err.Error()} + if c, ok := err.(coder); ok { + body["code"] = c.Code() + } + + ctx.Status(status) + return ctx.JSON(body) +} diff --git a/iamapi/private/handlers.go b/iamapi/private/handlers.go new file mode 100644 index 00000000..adf3ecdc --- /dev/null +++ b/iamapi/private/handlers.go @@ -0,0 +1,174 @@ +// 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 private + +import ( + "encoding/json" + "maps" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + "github.com/versity/versitygw/iamapi/policy" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" +) + +func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error { + var req DeriveSigningKeyRequest + if err := json.Unmarshal(ctx.Body(), &req); err != nil { + return errMalformedRequestBody + } + + _, secret, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken) + if err != nil { + return mapResolveError(err) + } + + derivedKey := sigv4auth.DeriveKey(secret, req.Date, req.Region, req.Service) + + return ctx.JSON(DeriveSigningKeyResponse{DerivedKey: derivedKey}) +} + +// handleResolveIdentity answers "does this access key exist, and what +// principal is it" for a batch of access key ids, returning no credential +// material at all — see ResolveIdentityResponse for why that is what makes +// answering for a session, with no session token, safe. +func (p *PrivateAPI) handleResolveIdentity(ctx fiber.Ctx) error { + var req ResolveIdentityRequest + if err := json.Unmarshal(ctx.Body(), &req); err != nil { + return errMalformedRequestBody + } + + resolved := resolveIdentityMetadata(ctx.Context(), p.store, req.AccessKeyIDs) + + identities := make([]ResolvedIdentity, len(resolved)) + for i, r := range resolved { + if !r.Found { + continue + } + identities[i] = ResolvedIdentity{ + Found: true, + Kind: identityKindWireValue(r.Kind), + PrincipalArn: r.PrincipalArn, + } + } + + return ctx.JSON(ResolveIdentityResponse{Identities: identities}) +} + +// identityKindWireValue converts identityKind to its wire representation. +func identityKindWireValue(k identityKind) string { + if k == identityKindSession { + return KindSession + } + return KindUser +} + +func (p *PrivateAPI) handleEvaluatePolicy(ctx fiber.Ctx) error { + var req EvaluatePolicyRequest + if err := json.Unmarshal(ctx.Body(), &req); err != nil { + return errMalformedRequestBody + } + + identity, _, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken) + if err != nil { + return mapResolveError(err) + } + + condition := conditionContextFor(*identity, req.Condition) + + decisions := make([][]string, len(req.Resources)) + sessionDecisions := make([][]string, len(req.Resources)) + hasSessionPolicy := false + + for i, resource := range req.Resources { + perAction := make([]string, len(req.Actions)) + perActionSession := make([]string, len(req.Actions)) + for j, action := range req.Actions { + identityDecision, sessionDecision, hasSession := iammiddleware.AuthorizeSplit(*identity, policy.RequestContext{ + Action: action, + Resource: resource, + Condition: condition, + }) + perAction[j] = decisionWireValue(identityDecision) + perActionSession[j] = decisionWireValue(sessionDecision) + hasSessionPolicy = hasSession + } + decisions[i] = perAction + sessionDecisions[i] = perActionSession + } + + resp := EvaluatePolicyResponse{ + Decisions: decisions, + PrincipalArn: iammiddleware.CallerArn(*identity), + } + if hasSessionPolicy { + resp.HasSessionPolicy = true + resp.SessionDecisions = sessionDecisions + } + + return ctx.JSON(resp) +} + +// conditionContextFor combines the request-derived condition keys the S3 +// gateway observed (source IP, time, transport) with the identity-derived +// keys only this service can know (aws:PrincipalArn, aws:username, …). +// +// Every key in an identity or resource namespace is dropped from the +// gateway's contribution first, then this side's own values are laid over +// the remainder. Filtering rather than merging matters: an +// override-on-collision merge would leave any key this service happens +// *not* to set — aws:PrincipalTag/x for an untagged role, say — under the +// gateway's control, which is precisely what a StringNotEquals-guarded +// Allow keys off. The gateway authenticates as root, so this is defense in +// depth rather than a trust boundary, but the layering costs nothing. +func conditionContextFor(identity types.Identity, requestKeys map[string][]string) map[string][]string { + condition := make(map[string][]string, len(requestKeys)) + for k, v := range requestKeys { + if isIdentityConditionKey(k) { + continue + } + condition[k] = v + } + maps.Copy(condition, iammiddleware.IdentityConditionContext(identity)) + return condition +} + +// isIdentityConditionKey reports whether key names the caller or the +// resource, and so may only be set by this service. Matching is +// case-insensitive because policy condition-key lookup is +// (iamapi/policy.lookupContextValues) — a caller must not be able to smuggle +// "AWS:PrincipalArn" past a case-sensitive filter. +func isIdentityConditionKey(key string) bool { + for _, prefix := range iammiddleware.IdentityConditionKeyPrefixes { + if strings.EqualFold(key, prefix) || + (strings.HasSuffix(prefix, "/") && len(key) > len(prefix) && strings.EqualFold(key[:len(prefix)], prefix)) { + return true + } + } + return false +} + +// decisionWireValue converts policy.Decision to its wire representation. +func decisionWireValue(d policy.Decision) string { + switch d { + case policy.DecisionAllow: + return DecisionAllow + case policy.DecisionDeny: + return DecisionDeny + default: + return DecisionNoMatch + } +} diff --git a/iamapi/private/identity.go b/iamapi/private/identity.go new file mode 100644 index 00000000..6da4825f --- /dev/null +++ b/iamapi/private/identity.go @@ -0,0 +1,103 @@ +// 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 private + +import ( + "context" + + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" +) + +// resolvePrivateIdentity resolves accessKeyID — long-term (AKIA…) or +// temporary (ASIA…) — to its identity and secret, for the S3 gateway to +// authenticate and authorize one of its own data-plane callers. +// +// sessionToken is required for, and only meaningful to, a temporary access +// key; it is what makes resolving a session here safe (see +// iamutil.ResolveSessionByToken). Errors are the iamutil.ErrIdentityNotFound +// / iamutil.ErrInvalidSessionToken sentinels, so the caller can report which +// failure occurred. +func resolvePrivateIdentity(ctx context.Context, store iamutil.IdentityStore, accessKeyID, sessionToken string) (*types.Identity, string, error) { + if sigv4auth.IsTempAccessKeyID(accessKeyID) { + return iamutil.ResolveSessionByToken(ctx, store, accessKeyID, sessionToken) + } + if sessionToken != "" { + // A token alongside a permanent credential is always a caller + // error, and accepting it silently would mask a misrouted request. + return nil, "", iamutil.ErrInvalidSessionToken + } + return iamutil.ResolveUserIdentity(ctx, store, accessKeyID) +} + +// identityKind labels what sort of principal an access key belongs to, for +// callers that need to tell an ephemeral session apart from a long-term user +// without holding a session token. +type identityKind string + +const ( + identityKindUser identityKind = "user" + identityKindSession identityKind = "session" +) + +// resolveIdentityMetadata answers "does this access key exist, and what +// principal is it" for each of accessKeyIDs, returning nothing that could +// authenticate anyone — no secret, no derived key, no policy. That is what +// makes it safe to resolve a temporary (ASIA…) key here with no session +// token: knowing a session exists grants nothing, whereas knowing its secret +// grants everything. +// +// It backs the S3 gateway's IAMService.GetUserAccount, whose only real +// consumer is auth.CheckIfAccountsExist — validating the principals named in +// a bucket policy or ACL, one batch per PutBucketPolicy/PutBucketAcl. +// Results are positional: one entry per input, with Found false for keys +// that don't resolve, rather than an error for the whole batch. +func resolveIdentityMetadata(ctx context.Context, store iamutil.IdentityStore, accessKeyIDs []string) []identityMetadata { + out := make([]identityMetadata, len(accessKeyIDs)) + for i, accessKeyID := range accessKeyIDs { + if sigv4auth.IsTempAccessKeyID(accessKeyID) { + session, err := store.GetSession(ctx, accessKeyID) + if err != nil { + continue + } + out[i] = identityMetadata{ + Found: true, + Kind: identityKindSession, + PrincipalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, session.RoleName, session.RoleSessionName), + } + continue + } + + identity, _, err := iamutil.ResolveUserIdentity(ctx, store, accessKeyID) + if err != nil { + continue + } + out[i] = identityMetadata{ + Found: true, + Kind: identityKindUser, + PrincipalArn: iammiddleware.CallerArn(*identity), + } + } + return out +} + +// identityMetadata is one resolveIdentityMetadata result. The zero value +// means "no such access key". +type identityMetadata struct { + Found bool + Kind identityKind + PrincipalArn string +} diff --git a/iamapi/private/listener.go b/iamapi/private/listener.go new file mode 100644 index 00000000..5e56d559 --- /dev/null +++ b/iamapi/private/listener.go @@ -0,0 +1,68 @@ +// 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 private + +import ( + "fmt" + "net" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/internal/netutil" +) + +const shutDownDuration = time.Second * 10 + +// ServeMultiPort binds and serves the private endpoints on every address in +// addrs. Each address is checked with netutil.RequireSecureTransport before +// binding anything — mTLS (server cert + mandatory client-cert +// verification) or a unix socket, nothing else — so a misconfiguration +// fails startup instead of silently serving these endpoints in the clear. +// tlsOpts is only applied to non-unix-socket addresses, or to a unix +// socket address if a server certificate is configured for it too. +func (p *PrivateAPI) ServeMultiPort(addrs []string, tlsOpts netutil.TLSOptions) error { + if len(addrs) == 0 { + return fmt.Errorf("no private listener addresses specified") + } + + hasMTLS := tlsOpts.GetCertificate != nil && tlsOpts.ClientCAs != nil && tlsOpts.RequireClientCert + for _, addr := range addrs { + if err := netutil.RequireSecureTransport(addr, hasMTLS); err != nil { + return err + } + } + + var listeners []net.Listener + for _, addr := range addrs { + var ln net.Listener + var err error + if netutil.IsUnixSocketPath(addr) && tlsOpts.GetCertificate == nil { + ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, addr, netutil.ListenerOptions{SocketPerm: p.socketPerm}) + } else { + ln, err = netutil.NewMultiAddrTLSListenerWithOptions(fiber.NetworkTCP, addr, tlsOpts, netutil.ListenerOptions{SocketPerm: p.socketPerm}) + } + if err != nil { + return fmt.Errorf("failed to bind private iam listener %s: %w", addr, err) + } + listeners = append(listeners, ln) + } + + finalListener := netutil.NewMultiListener(listeners...) + return p.app.Listener(finalListener, fiber.ListenConfig{DisableStartupMessage: true}) +} + +// Shutdown gracefully stops the private endpoint listeners. +func (p *PrivateAPI) Shutdown() error { + return p.app.ShutdownWithTimeout(shutDownDuration) +} diff --git a/iamapi/private/private_test.go b/iamapi/private/private_test.go new file mode 100644 index 00000000..bf424251 --- /dev/null +++ b/iamapi/private/private_test.go @@ -0,0 +1,694 @@ +// 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 private + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" +) + +var testRoot = iammiddleware.RootCredentials{Access: "AKIDTESTROOT", Secret: "TESTROOTSECRET"} + +// newTestServer builds a fresh file-backed store rooted at t.TempDir() and a +// PrivateAPI on top of it — no public control-plane IAMApiServer involved, +// since this package's handlers only ever need a populated storage.Storer. +func newTestServer(t *testing.T) (*PrivateAPI, storage.Storer) { + t.Helper() + + store, err := storage.New(storage.Config{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + + p, err := New(store, testRoot) + if err != nil { + t.Fatalf("New: %v", err) + } + return p, store +} + +// createTestUser creates a user with the given name, access key, and +// (optional) inline policy directly against store — bypassing the public +// control-plane API entirely, since it isn't under test here. Arn is set +// explicitly (iamutil.BuildUserArn, matching what the control-plane +// controller computes before calling storage.CreateUser — storage.CreateUser +// itself never populates it) so tests can assert on a realistic principal +// ARN in an evaluate-policy response. +func createTestUser(t *testing.T, store storage.Storer, userName, accessKeyID, secret, policyDocument string) { + t.Helper() + ctx := context.Background() + + if _, err := store.CreateUser(ctx, types.User{ + UserName: userName, + Path: "/", + Arn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", userName), + CreateDate: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if _, err := store.CreateAccessKey(ctx, storage.CreateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + SecretAccessKey: secret, + Status: "Active", + CreateDate: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + if policyDocument != "" { + if err := store.PutUserPolicy(ctx, storage.PutUserPolicyInput{ + UserName: userName, + PolicyName: "P", + PolicyDocument: policyDocument, + }); err != nil { + t.Fatalf("PutUserPolicy: %v", err) + } + } +} + +// signPrivateRequest signs req as access/secret for the private endpoints' +// SigV4 protocol (service "iam", iammiddleware.SigningRegion), mutating its +// Authorization/X-Amz-Date headers in place. +func signPrivateRequest(t *testing.T, req *http.Request, access, secret string, payloadHash string) { + t.Helper() + + signingTime := time.Now().UTC() + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, iammiddleware.SigningRegion, privateService) + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = access + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, iammiddleware.SigningRegion, privateService) + in.PayloadHash = payloadHash + in.SigningTime = signingTime + result := sigv4auth.BuildAndSign(derivedKey, in) + req.Header.Set("X-Amz-Date", result.AmzDate) + req.Header.Set("Authorization", result.AuthorizationHeader) +} + +func doPrivateRequest(t *testing.T, p *PrivateAPI, method, target, access, secret string, body []byte) *http.Response { + t.Helper() + + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(body)) + + hash := sigv4auth.PayloadSHA256Hex(body) + signPrivateRequest(t, req, access, secret, hash) + + resp, err := p.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(body) +} + +func TestPrivateAPIDeriveSigningKey(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD) + body, _ := json.Marshal(DeriveSigningKeyRequest{ + AccessKeyID: "AKIAALICE", + Date: yyyymmdd, + Region: "us-east-1", + Service: "s3", + }) + + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var got DeriveSigningKeyResponse + if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + want := sigv4auth.DeriveKey("alicesecret", yyyymmdd, "us-east-1", "s3") + if !bytes.Equal(got.DerivedKey, want) { + t.Errorf("DerivedKey = %x, want %x", got.DerivedKey, want) + } +} + +func TestPrivateAPIDeriveSigningKeyRejectsUnknownAccessKey(t *testing.T) { + p, _ := newTestServer(t) + + body, _ := json.Marshal(DeriveSigningKeyRequest{ + AccessKeyID: "AKIADOESNOTEXIST", + Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD), + Region: "us-east-1", + Service: "s3", + }) + + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp)) + } +} + +// TestPrivateAPIDeriveSigningKeySessionToken covers every way a temporary +// (ASIA…) access key can be presented to derive-signing-key. The security of +// the whole session path rests on exactly one thing — that a signing key is +// handed out only for a session token matching the one stored — so each +// wrong-token shape is pinned, along with the error code that tells the S3 +// gateway to report InvalidToken rather than InvalidAccessKeyId. +func TestPrivateAPIDeriveSigningKeySessionToken(t *testing.T) { + p, store := newTestServer(t) + role := createTestRole(t, store, "testrole", "") + session := createTestSessionForRole(t, store, role, "ASIASOMESESSIONKEY", "sessionsecret", "correct-session-token", "") + + tests := []struct { + name string + token string + wantStatus int + wantCode string + }{ + {name: "no token at all", token: "", wantStatus: http.StatusForbidden, wantCode: CodeNoSuchIdentity}, + {name: "wrong token", token: "wrong-session-token", wantStatus: http.StatusForbidden, wantCode: CodeInvalidToken}, + {name: "correct token", token: session.SessionToken, wantStatus: http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(DeriveSigningKeyRequest{ + AccessKeyID: session.AccessKeyId, + SessionToken: tt.token, + Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD), + Region: "us-east-1", + Service: "s3", + }) + + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body) + raw := readBody(t, resp) + if resp.StatusCode != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tt.wantStatus, raw) + } + if tt.wantCode != "" { + var errBody struct{ Code string } + if err := json.Unmarshal([]byte(raw), &errBody); err != nil { + t.Fatalf("unmarshal error body %s: %v", raw, err) + } + if errBody.Code != tt.wantCode { + t.Fatalf("error code = %q, want %q", errBody.Code, tt.wantCode) + } + return + } + + var out DeriveSigningKeyResponse + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + want := sigv4auth.DeriveKey("sessionsecret", time.Now().UTC().Format(sigv4auth.YYYYMMDD), "us-east-1", "s3") + if string(out.DerivedKey) != string(want) { + t.Errorf("derived key = %x, want %x", out.DerivedKey, want) + } + }) + } +} + +// TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey confirms a +// session token offered alongside a long-term (AKIA…) key is rejected rather +// than ignored — accepting it silently would mask a misrouted request. +func TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + body, _ := json.Marshal(DeriveSigningKeyRequest{ + AccessKeyID: "AKIAALICE", + SessionToken: "some-session-token", + Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD), + Region: "us-east-1", + Service: "s3", + }) + + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp)) + } +} + +// TestPrivateAPIEvaluatePolicySessionPolicy confirms the role's own policies +// and the session policy are reported *separately*, not folded together. +// +// The S3 gateway needs them apart because a bucket policy is also in play +// there: a session policy filters permissions that came from the bucket +// policy too, while the role's own decision does not. See +// iammiddleware.AuthorizeSplit. +func TestPrivateAPIEvaluatePolicySessionPolicy(t *testing.T) { + rolePolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"*"}]}` + + tests := []struct { + name string + sessionPolicy string + action string + want string + wantSession string + wantHasSessionPo bool + }{ + { + name: "no session policy: role decision stands alone", + action: "s3:GetObject", + want: DecisionAllow, + }, + { + name: "session policy narrows to a subset", + sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, + action: "s3:PutObject", + want: DecisionAllow, + wantSession: DecisionNoMatch, + wantHasSessionPo: true, + }, + { + name: "session policy cannot widen beyond the role", + sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`, + action: "s3:DeleteObject", + want: DecisionNoMatch, + wantSession: DecisionAllow, + wantHasSessionPo: true, + }, + { + name: "session policy explicit deny against the role's allow", + sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"*"}]}`, + action: "s3:GetObject", + want: DecisionAllow, + wantSession: DecisionDeny, + wantHasSessionPo: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, store := newTestServer(t) + role := createTestRole(t, store, "testrole", rolePolicy) + session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", tt.sessionPolicy) + + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: session.AccessKeyId, + SessionToken: session.SessionToken, + Actions: []string{tt.action}, + Resources: []string{"*"}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + raw := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw) + } + + var out EvaluatePolicyResponse + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want { + t.Errorf("Decisions = %v, want [[%v]]", out.Decisions, tt.want) + } + if out.HasSessionPolicy != tt.wantHasSessionPo { + t.Errorf("HasSessionPolicy = %v, want %v", out.HasSessionPolicy, tt.wantHasSessionPo) + } + if tt.wantHasSessionPo { + if len(out.SessionDecisions) != 1 || len(out.SessionDecisions[0]) != 1 || out.SessionDecisions[0][0] != tt.wantSession { + t.Errorf("SessionDecisions = %v, want [[%v]]", out.SessionDecisions, tt.wantSession) + } + } else if len(out.SessionDecisions) != 0 { + t.Errorf("SessionDecisions = %v, want none when no session policy applies", out.SessionDecisions) + } + wantArn := iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName) + if out.PrincipalArn != wantArn { + t.Errorf("PrincipalArn = %q, want %q", out.PrincipalArn, wantArn) + } + }) + } +} + +// TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys confirms the +// gateway cannot influence an identity-namespace condition key by sending +// one itself. Stripping rather than overriding matters for keys the service +// does not set at all: aws:PrincipalTag/team below has no value for an +// untagged user, and a policy that Allows on its *absence* must not be +// satisfiable by a value the caller supplied. +func TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+ + `"Condition":{"StringEquals":{"aws:PrincipalTag/team":"admins"}}}]}`) + + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: "AKIAALICE", + Actions: []string{"s3:GetObject"}, + Resources: []string{"*"}, + Condition: map[string][]string{ + "aws:PrincipalTag/team": {"admins"}, + // Case-varied spellings must be stripped too: policy key lookup + // is case-insensitive, so a case-sensitive filter would be no + // filter at all. + "AWS:PrincipalArn": {"arn:aws:iam::000000000000:user/somebodyelse"}, + }, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + raw := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw) + } + + var out EvaluatePolicyResponse + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != DecisionNoMatch { + t.Errorf("Decisions = %v, want [[%v]]: a caller-supplied aws:PrincipalTag must not satisfy the condition", out.Decisions, DecisionNoMatch) + } +} + +// TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys is the counterpart to +// the test above: the request-derived keys the gateway *is* the authority +// for must reach the policy evaluator intact. +func TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+ + `"Condition":{"IpAddress":{"aws:SourceIp":"10.1.2.0/24"}}}]}`) + + tests := []struct { + name string + sourceIP string + want string + }{ + {name: "matching source ip", sourceIP: "10.1.2.3", want: DecisionAllow}, + {name: "non-matching source ip", sourceIP: "10.9.9.9", want: DecisionNoMatch}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: "AKIAALICE", + Actions: []string{"s3:GetObject"}, + Resources: []string{"*"}, + Condition: map[string][]string{"aws:SourceIp": {tt.sourceIP}}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + raw := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw) + } + + var out EvaluatePolicyResponse + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want { + t.Errorf("Decisions = %v, want [%v]", out.Decisions, tt.want) + } + }) + } +} + +// TestPrivateAPIResolveIdentity covers the metadata-only endpoint: it must +// answer positionally for a whole batch, resolve a session with no token +// (the disclosure is harmless, since nothing it returns authenticates +// anyone), and label session versus user so the gateway can refuse to +// persist a reference to an ephemeral principal. +func TestPrivateAPIResolveIdentity(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + role := createTestRole(t, store, "testrole", "") + session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", "") + + body, _ := json.Marshal(ResolveIdentityRequest{ + AccessKeyIDs: []string{"AKIAALICE", "AKIADOESNOTEXIST", session.AccessKeyId}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, ResolveIdentityPath, testRoot.Access, testRoot.Secret, body) + raw := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw) + } + + var out ResolveIdentityResponse + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + + want := []ResolvedIdentity{ + {Found: true, Kind: KindUser, PrincipalArn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice")}, + {}, + {Found: true, Kind: KindSession, PrincipalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName)}, + } + if len(out.Identities) != len(want) { + t.Fatalf("Identities = %+v, want %d entries", out.Identities, len(want)) + } + for i := range want { + if out.Identities[i] != want[i] { + t.Errorf("Identities[%d] = %+v, want %+v", i, out.Identities[i], want[i]) + } + } +} + +func TestPrivateAPIEvaluatePolicy(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`) + wantArn := iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice") + + tests := []struct { + name string + action string + want string + }{ + {name: "allowed action", action: "s3:GetObject", want: DecisionAllow}, + {name: "action not granted", action: "s3:PutObject", want: DecisionNoMatch}, + {name: "explicitly denied action", action: "s3:DeleteObject", want: DecisionDeny}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: "AKIAALICE", + Actions: []string{tt.action}, + Resources: []string{"*"}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var got EvaluatePolicyResponse + if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(got.Decisions) != 1 || len(got.Decisions[0]) != 1 || got.Decisions[0][0] != tt.want { + t.Errorf("Decisions = %v, want [[%v]]", got.Decisions, tt.want) + } + if got.PrincipalArn != wantArn { + t.Errorf("PrincipalArn = %q, want %q", got.PrincipalArn, wantArn) + } + }) + } +} + +// TestPrivateAPIEvaluatePolicyBatchesMultipleActions confirms multiple +// actions supplied in one EvaluatePolicyRequest are each evaluated +// independently against the same resource, in a single request, with +// Decisions returned in the same order as Actions. +func TestPrivateAPIEvaluatePolicyBatchesMultipleActions(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`) + + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: "AKIAALICE", + Actions: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, + Resources: []string{"*"}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var got EvaluatePolicyResponse + if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + want := []string{DecisionAllow, DecisionNoMatch, DecisionDeny} + if len(got.Decisions) != 1 || len(got.Decisions[0]) != len(want) { + t.Fatalf("Decisions = %v, want [%v]", got.Decisions, want) + } + for i := range want { + if got.Decisions[0][i] != want[i] { + t.Errorf("Decisions[0][%d] = %v, want %v", i, got.Decisions[0][i], want[i]) + } + } +} + +// TestPrivateAPIEvaluatePolicyBatchesMultipleResources confirms several +// resources are each evaluated against every action in one request — what +// keeps a 1000-key DeleteObjects a single round trip. +func TestPrivateAPIEvaluatePolicyBatchesMultipleResources(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::b/allowed/*"}]}`) + + body, _ := json.Marshal(EvaluatePolicyRequest{ + AccessKeyID: "AKIAALICE", + Actions: []string{"s3:DeleteObject"}, + Resources: []string{"arn:aws:s3:::b/allowed/one", "arn:aws:s3:::b/denied/two", "arn:aws:s3:::b/allowed/three"}, + }) + + resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var got EvaluatePolicyResponse + if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + want := []string{DecisionAllow, DecisionNoMatch, DecisionAllow} + if len(got.Decisions) != len(want) { + t.Fatalf("Decisions = %v, want %d resource entries", got.Decisions, len(want)) + } + for i := range want { + if len(got.Decisions[i]) != 1 || got.Decisions[i][0] != want[i] { + t.Errorf("Decisions[%d] = %v, want [%v]", i, got.Decisions[i], want[i]) + } + } +} + +// TestPrivateAPIRejectsNonRootCredential confirms a validly-signed request +// from a real (non-root) IAM user's own credentials is rejected outright — +// only the S3 gateway's own root-equivalent identity may ever call these +// endpoints. +func TestPrivateAPIRejectsNonRootCredential(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + body, _ := json.Marshal(DeriveSigningKeyRequest{ + AccessKeyID: "AKIAALICE", + Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD), + Region: "us-east-1", + Service: "s3", + }) + + // Signed with alice's own, otherwise-valid credentials — not root. + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, "AKIAALICE", "alicesecret", body) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp)) + } +} + +func TestPrivateAPIRejectsMalformedBody(t *testing.T) { + p, _ := newTestServer(t) + + resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, []byte("not json")) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusBadRequest, readBody(t, resp)) + } +} + +func TestPrivateAPIRejectsUnsignedRequest(t *testing.T) { + p, _ := newTestServer(t) + + body, _ := json.Marshal(DeriveSigningKeyRequest{AccessKeyID: "AKIAX", Date: "20260101", Region: "us-east-1", Service: "s3"}) + req := httptest.NewRequest(http.MethodPost, DerivePath, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode == http.StatusOK { + t.Errorf("expected an unsigned request to be rejected, got 200") + } +} + +// createTestRole creates a role with an optional inline permission policy +// directly against store, the same way createTestUser bypasses the +// control-plane API. Arn and RoleID are set explicitly because +// storage.CreateRole doesn't populate them, and iamutil.ResolveSessionByToken +// re-checks both against the session before attaching the role's policies. +func createTestRole(t *testing.T, store storage.Storer, roleName, policyDocument string) *types.Role { + t.Helper() + ctx := context.Background() + + role, err := store.CreateRole(ctx, types.Role{ + RoleName: roleName, + Path: "/", + RoleID: "AROA" + roleName, + Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, "/", roleName), + CreateDate: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if policyDocument != "" { + if err := store.PutRolePolicy(ctx, storage.PutRolePolicyInput{ + RoleName: roleName, + PolicyName: "P", + PolicyDocument: policyDocument, + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + } + return role +} + +// createTestSessionForRole creates a session against role as +// AssumeRoleWithWebIdentity would, with an optional inline session policy. +// RoleID and RoleArn are copied from role so the session survives +// iamutil.ResolveSessionByToken's same-role re-check. +func createTestSessionForRole(t *testing.T, store storage.Storer, role *types.Role, accessKeyID, secret, token, sessionPolicy string) *types.Session { + t.Helper() + + session, err := store.CreateSession(context.Background(), types.Session{ + AccessKeyId: accessKeyID, + SecretAccessKey: secret, + SessionToken: token, + RoleArn: role.Arn, + RoleName: role.RoleName, + RoleID: role.RoleID, + RoleSessionName: "testsession", + CreateDate: time.Now().UTC(), + Expiration: time.Now().UTC().Add(time.Hour), + Policy: sessionPolicy, + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + return session +} diff --git a/iamapi/private/server.go b/iamapi/private/server.go new file mode 100644 index 00000000..6b465007 --- /dev/null +++ b/iamapi/private/server.go @@ -0,0 +1,124 @@ +// 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 private implements the standalone IAM service's private endpoint +// set: derive a SigV4 signing key, evaluate IAM identity policies, and +// resolve an access key id to the principal that owns it. All three exist +// purely so the S3 gateway can authenticate and authorize its own callers +// without ever holding a plaintext secret or a policy document itself. +// This is a separate fiber app from the public control-plane +// iamapi.IAMApiServer, meant to be served on its own listener(s), never the +// public one. +// +// The endpoints authenticate strictly as the configured root credential +// — only the S3 gateway itself, signing as its own IAM-client identity, ever +// legitimately calls them. Transport security is enforced by ServeMultiPort, +// not by request handling +package private + +import ( + "fmt" + "os" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/internal/sigv4auth" +) + +const ( + // These are exported so auth.IAMServiceStandalone (the S3-side client) + // shares one source of truth for the routes rather than duplicating the + // literal path strings. + DerivePath = "/private/derive-signing-key" + EvaluatePath = "/private/evaluate-policy" + ResolveIdentityPath = "/private/resolve-identity" + + // privateService is the SigV4 credential-scope service name the S3 + // gateway signs its own requests to these endpoints with. It's an + // internal detail — these routes aren't part of any AWS-compatible + // API — reusing "iam" is simplest and avoids inventing a new constant + // consumers on both sides would have to agree on. + privateService = sigv4auth.ServiceIAM +) + +// PrivateAPI is the standalone IAM service's private endpoint set +type PrivateAPI struct { + app *fiber.App + store storage.Storer + socketPerm os.FileMode + quiet bool +} + +type PrivateAPIOption func(*PrivateAPI) + +// WithPrivateSocketPerm sets the file-mode permission applied to any +// file-backed unix-socket listener address (no effect on TCP addresses or +// Linux abstract-namespace sockets). +func WithPrivateSocketPerm(perm os.FileMode) PrivateAPIOption { + return func(p *PrivateAPI) { p.socketPerm = perm } +} + +// WithPrivateQuiet suppresses per-request summary logging, mirroring +// iamapi.WithQuiet for the public API. Callers should gate both on the same +// flag so the two log streams turn on and off together. +func WithPrivateQuiet() PrivateAPIOption { + return func(p *PrivateAPI) { p.quiet = true } +} + +// New constructs the private endpoint set. root is the identity these +// endpoints authenticate every request against. +func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...PrivateAPIOption) (*PrivateAPI, error) { + if store == nil { + return nil, fmt.Errorf("iamapi/private: storer is required") + } + + p := &PrivateAPI{store: store} + for _, opt := range opts { + opt(p) + } + + app := fiber.New(fiber.Config{ + AppName: "versitygw-iam-private", + ServerHeader: "VERSITYGW", + ErrorHandler: p.errorHandler, + }) + p.app = app + + if !p.quiet { + app.Use("*", logger.New(logger.Config{ + Format: "${time} | vgw-iam-private | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + })) + } + + rootAuth := iammiddleware.VerifyRootOnlySigV4(privateService, &root) + app.Post(DerivePath, chainHandlers(rootAuth, p.handleDeriveSigningKey)) + app.Post(EvaluatePath, chainHandlers(rootAuth, p.handleEvaluatePolicy)) + app.Post(ResolveIdentityPath, chainHandlers(rootAuth, p.handleResolveIdentity)) + + return p, nil +} + +// chainHandlers composes handlers into one, calling each in turn and +// stopping at the first error. +func chainHandlers(handlers ...fiber.Handler) fiber.Handler { + return func(ctx fiber.Ctx) error { + for _, h := range handlers { + if err := h(ctx); err != nil { + return err + } + } + return nil + } +} diff --git a/iamapi/private/types.go b/iamapi/private/types.go new file mode 100644 index 00000000..2b438ec5 --- /dev/null +++ b/iamapi/private/types.go @@ -0,0 +1,114 @@ +// 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 private + +// DeriveSigningKeyRequest is the derive-signing-key request body. Date, +// Region, and Service are the request's credential-scope components +// (yyyymmdd/region/service), matching sigv4auth.DeriveKey's parameters. +// +// SessionToken is required when AccessKeyID is a temporary (ASIA…) key and +// must be absent otherwise. It is what makes resolving a session here safe: +// without it, anyone who learned a session's access key id could ask for +// that session's signing key. +type DeriveSigningKeyRequest struct { + AccessKeyID string `json:"accessKeyId"` + SessionToken string `json:"sessionToken,omitempty"` + Date string `json:"date"` + Region string `json:"region"` + Service string `json:"service"` +} + +// DeriveSigningKeyResponse carries the derived signing key (kSigning) — +// never the underlying secret. +type DeriveSigningKeyResponse struct { + DerivedKey []byte `json:"derivedKey"` +} + +// EvaluatePolicyRequest is the evaluate-policy request body +type EvaluatePolicyRequest struct { + AccessKeyID string `json:"accessKeyId"` + SessionToken string `json:"sessionToken,omitempty"` + Actions []string `json:"actions"` + Resources []string `json:"resources"` + Condition map[string][]string `json:"condition,omitempty"` +} + +// ResolveIdentityRequest asks whether each access key id exists and what +// principal it names. It carries no session token because the response +// carries no credential material +type ResolveIdentityRequest struct { + AccessKeyIDs []string `json:"accessKeyIds"` +} + +// ResolveIdentityResponse answers ResolveIdentityRequest positionally: one +// entry per requested access key id, in the same order, with Found false +// for one that doesn't resolve. It deliberately carries no secret, no +// derived key and no policy — only that makes it safe to answer for a +// temporary (ASIA…) key with no session token, since knowing a session +// exists grants nothing. +type ResolveIdentityResponse struct { + Identities []ResolvedIdentity `json:"identities"` +} + +// ResolvedIdentity is one ResolveIdentityResponse entry. Kind is +// KindUser or KindSession. +type ResolvedIdentity struct { + Found bool `json:"found"` + Kind string `json:"kind,omitempty"` + PrincipalArn string `json:"principalArn,omitempty"` +} + +// Kind values for ResolvedIdentity.Kind — strings on the wire for the same +// reason the Decision values below are: self-documenting, and immune to +// iota drift between the independently-built gateway and IAM service. +const ( + KindUser = "user" + KindSession = "session" +) + +// Decision values for each entry of EvaluatePolicyResponse.Decisions. +// Deliberately strings, not policy.Decision's int: self-documenting on the +// wire, and immune to iota drift between the S3 gateway and standalone IAM +// service — two independently-built processes speaking this protocol. +const ( + DecisionAllow = "allow" + DecisionDeny = "deny" + DecisionNoMatch = "no_match" +) + +// EvaluatePolicyResponse carries the full tri-state result for the whole +// requested matrix — not just whether each cell is allowed — so the S3 +// gateway can distinguish an explicit Deny (which must override an +// otherwise-allowing bucket policy) from a plain NoMatch (which doesn't), +// and can build an AWS-shaped denial message. +// +// Decisions[i][j] is the decision for the request's Resources[i] and +// Actions[j], in the order both were sent. PrincipalArn is the resolved +// identity's own ARN, best-effort: "" when it can't be resolved (e.g. a +// session whose role no longer exists), in which case the caller falls back +// to the access key. It is shared by the whole batch — one request always +// evaluates against a single identity. +// SessionDecisions is the same matrix evaluated against the caller's +// session policy alone, and HasSessionPolicy says whether one applied — the +// caller must ignore SessionDecisions when it is false. They are reported +// separately from Decisions rather than folded into them because a session +// policy filters *everything*, including permissions the S3 gateway's own +// bucket policy grants, which this service knows nothing about. See +// iammiddleware.AuthorizeSplit. +type EvaluatePolicyResponse struct { + Decisions [][]string `json:"decisions"` + SessionDecisions [][]string `json:"sessionDecisions,omitempty"` + HasSessionPolicy bool `json:"hasSessionPolicy,omitempty"` + PrincipalArn string `json:"principalArn,omitempty"` +} diff --git a/iamapi/server.go b/iamapi/server.go index 43d09a2c..a2dabad0 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -63,13 +63,14 @@ type IAMApiServer struct { oidcThumbprintAutoFetchDisabled bool } -func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { +func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiServer, error) { if store == nil { return nil, fmt.Errorf("iamapi: storer is required") } server := &IAMApiServer{ - store: store, + store: store, + rootCreds: &root, Router: &IAMApiRouter{ store: store, }, @@ -162,12 +163,6 @@ func WithOnListen(fn func()) Option { return func(s *IAMApiServer) { s.onListen = fn } } -func WithRootUserCreds(root RootCredentials) Option { - return func(s *IAMApiServer) { - s.rootCreds = &root - } -} - // WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's // TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an // omitted ThumbprintList is rejected with a MissingValue error instead of diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index db356bbf..e5d10da1 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -40,8 +40,7 @@ const vaultRequestTimeout = 10 * time.Second // withRoleCAS/withOIDCProviderCAS run when a version-checked (CAS) write // loses a race against a concurrent writer updating the same entity — // mirroring the 3-attempt collision-retry loops already used elsewhere in -// this package for ID generation (see controller.go's CreateUser/CreateRole/ -// CreateAccessKey). +// this package for ID generation. const maxCASRetries = 3 // errConcurrentModification is withUserCAS/withRoleCAS/withOIDCProviderCAS's @@ -359,7 +358,7 @@ func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, e // readUserVersion resolves username the same way GetUser does, additionally // returning the KV version the record was read at, so a mutation can write // back with a matching CAS value instead of racing on a blind -// delete-then-recreate (see replaceUser). +// delete-then-recreate. func (s *VaultStore) readUserVersion(username string) (*types.User, int32, error) { key := caseFoldKey(username) path := s.usersPath() + "/" + key @@ -752,13 +751,12 @@ const recordAccessKeyUsageTimeout = 5 * time.Second // RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed metadata // in its own background goroutine, detached from ctx, and always returns -// nil immediately: this runs on the hot path of every authenticated request -// (see iammiddleware.recordAccessKeyUsage), and a Vault round trip — plus, -// on a CAS conflict, withUserCAS's retry loop — is too expensive to add -// synchronously to every one of them. A failure (including one that -// exhausts those retries) is only logged, never surfaced: this is purely -// informational metadata, and a lost update under concurrent use is -// immaterial. +// nil immediately: this runs on the hot path of every authenticated +// request, and a Vault round trip — plus, on a CAS conflict, withUserCAS's +// retry loop — is too expensive to add synchronously to every one of them. +// A failure (including one that exhausts those retries) is only logged, +// never surfaced: this is purely informational metadata, and a lost update +// under concurrent use is immaterial. func (s *VaultStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error { go func() { ctx, cancel := context.WithTimeout(context.Background(), recordAccessKeyUsageTimeout) @@ -1043,7 +1041,7 @@ func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, e // readRoleVersion is GetRole's counterpart to readUserVersion: it // additionally returns the KV version the record was read at, so a // mutation can write back with a matching CAS value instead of racing on a -// blind delete-then-recreate (see replaceRole). +// blind delete-then-recreate. func (s *VaultStore) readRoleVersion(roleName string) (*types.Role, int32, error) { key := caseFoldKey(roleName) path := s.rolesPath() + "/" + key @@ -1858,12 +1856,12 @@ func (s *VaultStore) GetSession(_ context.Context, accessKeyID string) (*types.S if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { // Either this access key never existed, or Vault's own - // delete_version_after TTL (see setSessionTTL) already - // soft-deleted the version — confirmed live: Vault answers a - // read for a soft-deleted-but-not-yet-destroyed version with - // 404, not 200-with-null-data. Either way, best-effort purge - // the lingering metadata record now, since Vault doesn't - // appear to reclaim it on its own once merely soft-deleted. + // delete_version_after TTL already soft-deleted the version — + // Vault answers a read for a soft-deleted-but-not-yet-destroyed + // version with 404, not 200-with-null-data. Either way, + // best-effort purge the lingering metadata record now, since + // Vault doesn't appear to reclaim it on its own once merely + // soft-deleted. s.purgeSession(accessKeyID) return nil, ErrSessionNotFound } diff --git a/iamapi/policy/condition.go b/internal/condition/condition.go similarity index 61% rename from iamapi/policy/condition.go rename to internal/condition/condition.go index 64fa9a86..3b7d0f9a 100644 --- a/iamapi/policy/condition.go +++ b/internal/condition/condition.go @@ -12,11 +12,20 @@ // specific language governing permissions and limitations // under the License. -package policy +// Package condition implements AWS IAM's policy Condition grammar and +// evaluation semantics: the operator registry (StringEquals, IpAddress, +// DateGreaterThan, ...), the ForAllValues/ForAnyValue/IfExists modifiers, +// and ${...} policy-variable substitution. It has no knowledge of any +// particular policy type (identity, trust, or resource-based) — callers +// supply a statement's raw Condition block, a request's context-key values, +// and the enclosing document's Version, and get back whether the condition +// holds. This lets both iamapi/policy (IAM identity/trust policies) and +// auth (S3 bucket policies) share one implementation and one AWS-verified +// behavior, rather than maintaining two. +package condition import ( "bytes" - "encoding/base64" "encoding/json" "fmt" "net" @@ -28,14 +37,14 @@ import ( "github.com/versity/versitygw/debuglogger" ) -// ConditionValues decodes the value(s) of a single Condition operator/key -// pair. Unlike Action/Resource's string-only StringOrSlice, a Condition -// value may also be a bare JSON number or boolean rather than -// being re-serialized, so e.g. "5.50" round-trips as "5.50", not "5.5". A -// JSON null value or a non-scalar (object/array) element is rejected. -type ConditionValues []string +// Values decodes the value(s) of a single Condition operator/key pair. +// Unlike Action/Resource's string-only representation, a Condition value may +// also be a bare JSON number or boolean rather than a string, so e.g. "5.50" +// round-trips as "5.50", not "5.5". A JSON null value or a non-scalar +// (object/array) element is rejected. +type Values []string -func (c *ConditionValues) UnmarshalJSON(data []byte) error { +func (c *Values) UnmarshalJSON(data []byte) error { trimmed := bytes.TrimSpace(data) if len(trimmed) > 0 && trimmed[0] == '[' { var raws []json.RawMessage @@ -58,7 +67,7 @@ func (c *ConditionValues) UnmarshalJSON(data []byte) error { if !ok { return fmt.Errorf("policy: invalid condition value %s", trimmed) } - *c = ConditionValues{s} + *c = Values{s} return nil } @@ -90,14 +99,18 @@ func decodeConditionScalar(raw json.RawMessage) (string, bool) { return num.String(), true } -// conditionQualifier is IAM's multivalued-context-key set operator, given as -// a "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name. -type conditionQualifier int +// Block is a statement's Condition object, decoded to operator name -> key +// -> value(s). +type Block map[string]map[string]Values + +// Qualifier is IAM's multivalued-context-key set operator, given as a +// "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name. +type Qualifier int const ( - qualifierNone conditionQualifier = iota - qualifierForAllValues - qualifierForAnyValue + QualifierNone Qualifier = iota + QualifierForAllValues + QualifierForAnyValue ) // conditionComparator is a single (policy value, request value) match test @@ -145,10 +158,17 @@ var conditionRegistry = map[string]conditionOperatorDef{ "Bool": {compare: boolMatch}, - "BinaryEquals": {compare: binaryMatch}, + // BinaryEquals is a plain string comparison, not a base64-decode-then- + // compare: AWS's own IAM condition-operator reference documents the + // request context value as itself the base64 text (the same string + // that appears in the policy on a match), never the decoded raw bytes + // - live-verified via iam:SimulateCustomPolicy, which also rejects a + // non-base64 binary-typed context value outright. Do not "fix" this to + // decode either side. + "BinaryEquals": {compare: stringExact}, // ArnEquals and ArnLike behave identically in real AWS (both wildcard - // -aware), and are matched here with the same whole-string globMatch + // -aware), and are matched here with the same whole-string GlobMatch // already used for Action/Resource - do not "fix" ArnEquals to a strict // == later, that would diverge from AWS behavior. "ArnEquals": {compare: stringLike}, @@ -162,7 +182,7 @@ var conditionRegistry = map[string]conditionOperatorDef{ func stringExact(expected, actual string) bool { return expected == actual } func stringFold(expected, actual string) bool { return strings.EqualFold(expected, actual) } -func stringLike(expected, actual string) bool { return globMatch(expected, actual) } +func stringLike(expected, actual string) bool { return GlobMatch(expected, actual) } // numericCompare builds a comparator from a (actual, expected float64) -> // bool test, matching AWS's direction convention (the request's value is @@ -210,10 +230,26 @@ func boolMatch(expected, actual string) bool { return eerr == nil && aerr == nil && e == a } -func binaryMatch(expected, actual string) bool { - e, eerr := base64.StdEncoding.DecodeString(expected) - a, aerr := base64.StdEncoding.DecodeString(actual) - return eerr == nil && aerr == nil && bytes.Equal(e, a) +// normalizeIPOrCIDR appends a full-length prefix ("/32" or "/128") to s when +// it names a bare address rather than a CIDR range, so a single address and +// its equivalent /32 or /128 range are always handled the same way. +func normalizeIPOrCIDR(s string) string { + if strings.Contains(s, "/") { + return s + } + if ip := net.ParseIP(s); ip != nil && ip.To4() != nil { + return s + "/32" + } + return s + "/128" +} + +// ParseIPOrCIDR reports whether s is a valid IP address or CIDR range, for +// write-time validation of an IP-semantic condition key's value (e.g. AWS +// rejects PutBucketPolicy for a non-IP aws:SourceIp value with "Invalid IP +// address in Conditions", independent of which operator wraps it). +func ParseIPOrCIDR(s string) bool { + _, _, err := net.ParseCIDR(normalizeIPOrCIDR(s)) + return err == nil } // ipMatch reports whether actual (an address) falls within cidr (a CIDR @@ -221,15 +257,7 @@ func binaryMatch(expected, actual string) bool { // IpAddress/NotIpAddress condition operators. An unparseable operand on // either side never matches (fails closed) rather than erroring. func ipMatch(cidr, actual string) bool { - c := cidr - if !strings.Contains(c, "/") { - if ip := net.ParseIP(c); ip != nil && ip.To4() != nil { - c += "/32" - } else { - c += "/128" - } - } - _, network, err := net.ParseCIDR(c) + _, network, err := net.ParseCIDR(normalizeIPOrCIDR(cidr)) if err != nil { return false } @@ -237,63 +265,74 @@ func ipMatch(cidr, actual string) bool { return ip != nil && network.Contains(ip) } -// parsedOperator is a condition operator name decomposed into its set +// ParsedOperator is a condition operator name decomposed into its set // qualifier, base operator, and IfExists flag. -type parsedOperator struct { - qualifier conditionQualifier - base string - ifExists bool +type ParsedOperator struct { + Qualifier Qualifier + Base string + IfExists bool } -// parseOperatorName decomposes name (e.g. "ForAllValues:StringNotEqualsIfExists") -// into a parsedOperator, reporting ok=false if the base operator (after +// ParseOperatorName decomposes name (e.g. "ForAllValues:StringNotEqualsIfExists") +// into a ParsedOperator, reporting ok=false if the base operator (after // stripping a recognized qualifier prefix and IfExists suffix) isn't one // conditionRegistry recognizes, or is "Null" (Null has no IfExists variant - // "NullIfExists" is rejected here since after suffix-stripping "Null" isn't // itself in conditionRegistry). A bare "Null", optionally qualifier-prefixed, is accepted -func parseOperatorName(name string) (parsedOperator, bool) { +func ParseOperatorName(name string) (ParsedOperator, bool) { op := name - qualifier := qualifierNone + qualifier := QualifierNone switch { case strings.HasPrefix(op, "ForAllValues:"): - qualifier = qualifierForAllValues + qualifier = QualifierForAllValues op = strings.TrimPrefix(op, "ForAllValues:") case strings.HasPrefix(op, "ForAnyValue:"): - qualifier = qualifierForAnyValue + qualifier = QualifierForAnyValue op = strings.TrimPrefix(op, "ForAnyValue:") } if op == "Null" { - return parsedOperator{qualifier: qualifier, base: "Null"}, true + return ParsedOperator{Qualifier: qualifier, Base: "Null"}, true } base := strings.TrimSuffix(op, "IfExists") ifExists := base != op if _, ok := conditionRegistry[base]; !ok { - return parsedOperator{}, false + return ParsedOperator{}, false } - return parsedOperator{qualifier: qualifier, base: base, ifExists: ifExists}, true + return ParsedOperator{Qualifier: qualifier, Base: base, IfExists: ifExists}, true } -// conditionShapeValid checks raw (a statement's Condition block) against -// IAM's condition grammar for write-time validation: an object of operator -// -> (key -> value), where every operator name is recognized by -// parseOperatorName. An absent, null, or empty Condition is valid (matches -// evaluateCondition's "always matches" contract). -func conditionShapeValid(raw json.RawMessage) bool { +// Parse decodes raw (a statement's Condition block) into a Block, validating +// only its JSON shape and that every operator name is one ParseOperatorName +// recognizes - not condition key names, which are meaningful only to a +// specific policy type (IAM identity policies accept arbitrary custom/tag +// keys; S3 bucket policies validate against AWS's fixed key catalogue) and +// so are the caller's responsibility. An absent, null, or empty raw decodes +// to a nil Block with no error, matching Evaluate's "always matches" +// contract for a statement with no Condition at all. +func Parse(raw json.RawMessage) (Block, error) { if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { - return true + return nil, nil } - var block map[string]map[string]ConditionValues + var block Block if err := json.Unmarshal(raw, &block); err != nil { - return false + return nil, err } for operator := range block { - if _, ok := parseOperatorName(operator); !ok { - return false + if _, ok := ParseOperatorName(operator); !ok { + return nil, fmt.Errorf("policy: unrecognized condition operator %q", operator) } } - return true + return block, nil +} + +// ShapeValid reports whether raw (a statement's Condition block) satisfies +// Parse without error - write-time validation of the condition grammar +// alone (operator names), with no opinion on condition keys. +func ShapeValid(raw json.RawMessage) bool { + _, err := Parse(raw) + return err == nil } // conditionVariableOperators is the subset of conditionRegistry that AWS @@ -316,48 +355,39 @@ var conditionVariableOperators = map[string]bool{ "ArnNotLike": true, } -// evaluateCondition evaluates a policy statement's Condition block against -// ctxVars - a ":" keyed context for trust-policy -// evaluation, or an "aws:" keyed context for identity-policy -// evaluation. An absent or empty Condition always matches. version is the -// enclosing document's Version element: a ${...} policy variable in a -// Condition value is only ever substituted when version is exactly -// Version2012 AND the operator is one of conditionVariableOperators - -// AWS requires the 2012-10-17 policy version to use variables at all, and -// never expands them for Numeric/Date/Bool/Binary/IP/Null operators even -// then. A variable that doesn't qualify is left as literal text, the -// same fallback used for an absent/multivalued context key - so it simply -// won't match a real condition value, rather than silently expanding into -// something AWS itself wouldn't. +// Evaluate evaluates a policy statement's Condition block against ctxVars - +// context-key values keyed however the caller's policy type documents them +// (e.g. "aws:" for IAM identity/S3 bucket policies, +// ":" for trust-policy evaluation). An absent or empty +// Condition always matches. version is the enclosing document's Version +// element: a ${...} policy variable in a Condition value is only ever +// substituted when version is exactly "2012-10-17" AND the operator is one +// of conditionVariableOperators - AWS requires the 2012-10-17 policy version +// to use variables at all, and never expands them for +// Numeric/Date/Bool/Binary/IP/Null operators even then. A variable that +// doesn't qualify is left as literal text, the same fallback used for an +// absent/multivalued context key - so it simply won't match a real +// condition value, rather than silently expanding into something AWS itself +// wouldn't. // // matched reports whether the condition holds; ok reports whether it could -// be evaluated at all. ok is false only for a Condition block whose JSON -// shape or operator name conditionShapeValid would already reject - i.e. -// only for a document stored before that write-time validation existed, or -// containing a future operator this package doesn't yet recognize. Callers -// MUST treat ok=false as "cannot rule out a hidden Deny" and deny the whole -// evaluation, never as a non-match - see EvaluateIdentityPolicies and -// EvaluateWebIdentityTrust. -func evaluateCondition(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) { - if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { - return true, true - } - - var block map[string]map[string]ConditionValues - if err := json.Unmarshal(raw, &block); err != nil { +// be evaluated at all. ok is false only for a Condition block Parse would +// already reject - i.e. only for a document stored before write-time +// validation existed, or containing a future operator this package doesn't +// yet recognize. Callers MUST treat ok=false as "cannot rule out a hidden +// Deny" and deny the whole evaluation, never as a non-match. +func Evaluate(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) { + block, err := Parse(raw) + if err != nil { debuglogger.Logf("policy condition block failed to parse: %v", err) return false, false } for operator, kvs := range block { - op, recognized := parseOperatorName(operator) - if !recognized { - debuglogger.Logf("policy condition: unrecognized operator %q", operator) - return false, false - } + op, _ := ParseOperatorName(operator) // Parse already validated every operator name for key, expected := range kvs { actual, present := lookupContextValues(ctxVars, key) - if version == Version2012 && conditionVariableOperators[op.base] { + if version == version2012 && conditionVariableOperators[op.Base] { expected = substituteConditionValues(expected, ctxVars) } if !evaluateConditionKey(op, expected, actual, present) { @@ -368,6 +398,12 @@ func evaluateCondition(raw json.RawMessage, ctxVars map[string][]string, version return true, true } +// version2012 is AWS's "2012-10-17" policy-document version string, the +// only one that enables ${...} policy-variable substitution. Duplicated +// here (rather than imported) since this package has no dependency on any +// specific policy type's Version constants. +const version2012 = "2012-10-17" + // lookupContextValues retrieves ctxVars[key], matching key // case-insensitively: AWS documents condition (and policy-variable) key // *names* as case-insensitive - "aws:SourceIp" and "AWS:SOURCEIP" name the @@ -390,14 +426,14 @@ func lookupContextValues(ctxVars map[string][]string, key string) ([]string, boo // placeholder, e.g. "${aws:username}". var policyVariablePattern = regexp.MustCompile(`\$\{([A-Za-z0-9_:.\-]+)\}`) -// substitutePolicyVariables replaces every ${key} placeholder in s with the +// SubstitutePolicyVariables replaces every ${key} placeholder in s with the // single value ctxVars holds for key, looked up the same case-insensitive // way as a Condition key. AWS only allows a single-valued context key to be // used as a policy variable; a placeholder naming an absent or multivalued // key is left as literal text, same as any other substring - so it simply // won't match a real resource ARN or condition value, rather than being // silently dropped and turning a Deny that relies on it into a no-op. -func substitutePolicyVariables(s string, ctxVars map[string][]string) string { +func SubstitutePolicyVariables(s string, ctxVars map[string][]string) string { if !strings.Contains(s, "${") { return s } @@ -411,14 +447,14 @@ func substitutePolicyVariables(s string, ctxVars map[string][]string) string { }) } -// substituteConditionValues applies substitutePolicyVariables to every +// substituteConditionValues applies SubstitutePolicyVariables to every // element of values, so e.g. a Condition of // {"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}} compares // against the requester's own username rather than the literal text. -func substituteConditionValues(values ConditionValues, ctxVars map[string][]string) ConditionValues { - out := make(ConditionValues, len(values)) +func substituteConditionValues(values Values, ctxVars map[string][]string) Values { + out := make(Values, len(values)) for i, v := range values { - out[i] = substitutePolicyVariables(v, ctxVars) + out[i] = SubstitutePolicyVariables(v, ctxVars) } return out } @@ -426,25 +462,25 @@ func substituteConditionValues(values ConditionValues, ctxVars map[string][]stri // evaluateConditionKey evaluates one operator/key pair of an already // -parsed Condition block against actual (ctxVars[key]) and present // (whether key was in ctxVars at all). -func evaluateConditionKey(op parsedOperator, expected ConditionValues, actual []string, present bool) bool { - if op.base == "Null" { +func evaluateConditionKey(op ParsedOperator, expected Values, actual []string, present bool) bool { + if op.Base == "Null" { return evaluateNull(expected, present) } - entry := conditionRegistry[op.base] // guaranteed present - parseOperatorName already validated op.base + entry := conditionRegistry[op.Base] // guaranteed present - ParseOperatorName already validated op.Base - if op.qualifier == qualifierForAllValues && !present { + if op.Qualifier == QualifierForAllValues && !present { return true } if entry.negate { if !present { return true } - return aggregate(op.qualifier, true, expected, actual, entry.compare) + return aggregate(op.Qualifier, true, expected, actual, entry.compare) } if !present { - return op.ifExists + return op.IfExists } - return aggregate(op.qualifier, false, expected, actual, entry.compare) + return aggregate(op.Qualifier, false, expected, actual, entry.compare) } // evaluateNull implements the Null condition operator: true if expected @@ -452,7 +488,7 @@ func evaluateConditionKey(op parsedOperator, expected ConditionValues, actual [] // must be absent ("true") and it is, or must be present ("false") and it // is. A value that's neither "true" nor "false" never satisfies the // condition (fails closed) -func evaluateNull(expected ConditionValues, present bool) bool { +func evaluateNull(expected Values, present bool) bool { for _, e := range expected { switch { case strings.EqualFold(e, "true"): @@ -472,7 +508,7 @@ func evaluateNull(expected ConditionValues, present bool) bool { // under qualifier's multivalued-context-key semantics. negate selects the // Not-operator family, sharing the same per-pair comparator as its positive // counterpart (see conditionRegistry). -func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValues, actual []string, cmp conditionComparator) bool { +func aggregate(qualifier Qualifier, negate bool, expected Values, actual []string, cmp conditionComparator) bool { matchesAny := func(a string) bool { for _, e := range expected { if cmp(e, a) { @@ -482,7 +518,7 @@ func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValu return false } - useForAll := qualifier == qualifierForAllValues || (qualifier == qualifierNone && negate) + useForAll := qualifier == QualifierForAllValues || (qualifier == QualifierNone && negate) if useForAll { for _, a := range actual { if ok := matchesAny(a); ok == negate { @@ -498,3 +534,32 @@ func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValu } return false // vacuously false over an empty/absent actual } + +// GlobMatch implements the small wildcard grammar IAM Action/Resource/Arn +// patterns use: '*' matches any run of characters (including none), '?' +// matches exactly one character, everything else matches literally. +func GlobMatch(pattern, s string) bool { + var pi, si, star, match int + star = -1 + for si < len(s) { + switch { + case pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == s[si]): + pi++ + si++ + case pi < len(pattern) && pattern[pi] == '*': + star = pi + match = si + pi++ + case star != -1: + pi = star + 1 + match++ + si = match + default: + return false + } + } + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + return pi == len(pattern) +} diff --git a/iamapi/policy/condition_test.go b/internal/condition/condition_test.go similarity index 90% rename from iamapi/policy/condition_test.go rename to internal/condition/condition_test.go index 121d4839..72c161c3 100644 --- a/iamapi/policy/condition_test.go +++ b/internal/condition/condition_test.go @@ -12,25 +12,31 @@ // specific language governing permissions and limitations // under the License. -package policy +package condition import ( "reflect" "testing" ) +// testVersion2012 is the "2012-10-17" policy-document version string, +// duplicated here rather than exported from the package (it's meaningful +// only to a caller's own Version type, e.g. iamapi/policy.Version2012 or +// auth.PolicyVersion2012). +const testVersion2012 = "2012-10-17" + // evalCondTest is the shared table shape for every TestEvaluateCondition* -// function below. wantErr means "evaluateCondition's ok return should be -// false" (the block's shape or an operator name couldn't be recognized) - -// distinct from want=false, which means the condition was evaluated fine -// but didn't match. +// function below. wantErr means "Evaluate's ok return should be false" (the +// block's shape or an operator name couldn't be recognized) - distinct from +// want=false, which means the condition was evaluated fine but didn't +// match. type evalCondTest struct { name string raw string ctxVars map[string][]string // version is the enclosing document's Version element: a Condition // value's ${...} policy variable is only ever substituted - // when this is exactly Version2012. Left "" (no Version) for every + // when this is exactly testVersion2012. Left "" (no Version) for every // existing case except the ones specifically testing substitution. version string want bool @@ -41,13 +47,13 @@ func runEvalCondTests(t *testing.T, tests []evalCondTest) { t.Helper() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matched, ok := evaluateCondition([]byte(tt.raw), tt.ctxVars, tt.version) + matched, ok := Evaluate([]byte(tt.raw), tt.ctxVars, tt.version) wantOk := !tt.wantErr if ok != wantOk { - t.Fatalf("evaluateCondition() ok = %v, want %v", ok, wantOk) + t.Fatalf("Evaluate() ok = %v, want %v", ok, wantOk) } if ok && matched != tt.want { - t.Errorf("evaluateCondition() matched = %v, want %v", matched, tt.want) + t.Errorf("Evaluate() matched = %v, want %v", matched, tt.want) } }) } @@ -224,14 +230,14 @@ func TestEvaluateCondition(t *testing.T) { name: "policy variable in condition value is substituted under version 2012-10-17", raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`, ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}}, - version: Version2012, + version: testVersion2012, want: true, }, { name: "policy variable naming an absent key is left literal and so fails to match", raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:nonexistent}"}}`, ctxVars: map[string][]string{"iam:ResourceTag/owner": {"alice"}}, - version: Version2012, + version: testVersion2012, want: false, }, { @@ -251,7 +257,7 @@ func TestEvaluateCondition(t *testing.T) { name: "policy variable is not substituted inside NumericEquals even under version 2012-10-17", raw: `{"NumericEquals":{"aws:EpochTime":"${aws:EpochTime}"}}`, ctxVars: map[string][]string{"aws:EpochTime": {"1700000000"}}, - version: Version2012, + version: testVersion2012, want: false, }, }) @@ -467,9 +473,9 @@ func TestEvaluateConditionBinary(t *testing.T) { want: false, }, { - name: "BinaryEquals invalid base64 fails closed, not an error", + name: "BinaryEquals decoded raw bytes do not match the base64 policy value", raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, - ctxVars: map[string][]string{"example.com:token": {"not-valid-base64!!"}}, + ctxVars: map[string][]string{"example.com:token": {"hello"}}, want: false, }, }) @@ -656,20 +662,20 @@ func TestEvaluateConditionQualifiers(t *testing.T) { }) } -func TestConditionValuesUnmarshalJSON(t *testing.T) { +func TestValuesUnmarshalJSON(t *testing.T) { tests := []struct { name string json string - want ConditionValues + want Values wantErr bool }{ - {"string", `"alice"`, ConditionValues{"alice"}, false}, - {"integer number, unquoted", `5`, ConditionValues{"5"}, false}, - {"decimal number preserves literal text", `5.50`, ConditionValues{"5.50"}, false}, - {"bool true", `true`, ConditionValues{"true"}, false}, - {"bool false", `false`, ConditionValues{"false"}, false}, - {"array of strings", `["a","b"]`, ConditionValues{"a", "b"}, false}, - {"array mixing string/number/bool", `["a",5,true]`, ConditionValues{"a", "5", "true"}, false}, + {"string", `"alice"`, Values{"alice"}, false}, + {"integer number, unquoted", `5`, Values{"5"}, false}, + {"decimal number preserves literal text", `5.50`, Values{"5.50"}, false}, + {"bool true", `true`, Values{"true"}, false}, + {"bool false", `false`, Values{"false"}, false}, + {"array of strings", `["a","b"]`, Values{"a", "b"}, false}, + {"array mixing string/number/bool", `["a",5,true]`, Values{"a", "5", "true"}, false}, {"null is rejected", `null`, nil, true}, {"null array element is rejected", `["a",null]`, nil, true}, {"nested array element is rejected", `[["a"]]`, nil, true}, @@ -678,7 +684,7 @@ func TestConditionValuesUnmarshalJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var got ConditionValues + var got Values err := got.UnmarshalJSON([]byte(tt.json)) if tt.wantErr { if err == nil { @@ -703,7 +709,7 @@ func TestParseOperatorName(t *testing.T) { wantOk bool wantBase string wantIfExists bool - wantQualif conditionQualifier + wantQualif Qualifier }{ {name: "StringEquals", op: "StringEquals", wantOk: true, wantBase: "StringEquals"}, {name: "StringEqualsIfExists", op: "StringEqualsIfExists", wantOk: true, wantBase: "StringEquals", wantIfExists: true}, @@ -715,9 +721,9 @@ func TestParseOperatorName(t *testing.T) { {name: "ArnLike", op: "ArnLike", wantOk: true, wantBase: "ArnLike"}, {name: "IpAddress", op: "IpAddress", wantOk: true, wantBase: "IpAddress"}, {name: "Null", op: "Null", wantOk: true, wantBase: "Null"}, - {name: "ForAllValues:StringEquals", op: "ForAllValues:StringEquals", wantOk: true, wantBase: "StringEquals", wantQualif: qualifierForAllValues}, - {name: "ForAnyValue:StringNotEqualsIfExists", op: "ForAnyValue:StringNotEqualsIfExists", wantOk: true, wantBase: "StringNotEquals", wantIfExists: true, wantQualif: qualifierForAnyValue}, - {name: "ForAllValues:Null accepted, qualifier is a no-op", op: "ForAllValues:Null", wantOk: true, wantBase: "Null", wantQualif: qualifierForAllValues}, + {name: "ForAllValues:StringEquals", op: "ForAllValues:StringEquals", wantOk: true, wantBase: "StringEquals", wantQualif: QualifierForAllValues}, + {name: "ForAnyValue:StringNotEqualsIfExists", op: "ForAnyValue:StringNotEqualsIfExists", wantOk: true, wantBase: "StringNotEquals", wantIfExists: true, wantQualif: QualifierForAnyValue}, + {name: "ForAllValues:Null accepted, qualifier is a no-op", op: "ForAllValues:Null", wantOk: true, wantBase: "Null", wantQualif: QualifierForAllValues}, {name: "NullIfExists rejected", op: "NullIfExists", wantOk: false}, {name: "unrecognized base", op: "FooBarOperator", wantOk: false}, {name: "unrecognized qualifier prefix left as part of the name", op: "ForSomeValues:StringEquals", wantOk: false}, @@ -726,15 +732,15 @@ func TestParseOperatorName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := parseOperatorName(tt.op) + got, ok := ParseOperatorName(tt.op) if ok != tt.wantOk { - t.Fatalf("parseOperatorName(%q) ok = %v, want %v", tt.op, ok, tt.wantOk) + t.Fatalf("ParseOperatorName(%q) ok = %v, want %v", tt.op, ok, tt.wantOk) } if !ok { return } - if got.base != tt.wantBase || got.ifExists != tt.wantIfExists || got.qualifier != tt.wantQualif { - t.Fatalf("parseOperatorName(%q) = %+v, want {base:%q ifExists:%v qualifier:%v}", tt.op, got, tt.wantBase, tt.wantIfExists, tt.wantQualif) + if got.Base != tt.wantBase || got.IfExists != tt.wantIfExists || got.Qualifier != tt.wantQualif { + t.Fatalf("ParseOperatorName(%q) = %+v, want {base:%q ifExists:%v qualifier:%v}", tt.op, got, tt.wantBase, tt.wantIfExists, tt.wantQualif) } }) } @@ -754,8 +760,29 @@ func TestGlobMatch(t *testing.T) { {pattern: "exact", s: "exacts", want: false}, } for _, tt := range tests { - if got := globMatch(tt.pattern, tt.s); got != tt.want { - t.Errorf("globMatch(%q, %q) = %v, want %v", tt.pattern, tt.s, got, tt.want) + if got := GlobMatch(tt.pattern, tt.s); got != tt.want { + t.Errorf("GlobMatch(%q, %q) = %v, want %v", tt.pattern, tt.s, got, tt.want) } } } + +func TestParseIPOrCIDR(t *testing.T) { + tests := []struct { + name string + s string + want bool + }{ + {"bare IPv4 valid", "203.0.113.5", true}, + {"CIDR valid", "10.0.0.0/8", true}, + {"bare IPv6 valid", "::1", true}, + {"garbage", "not-an-ip", false}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseIPOrCIDR(tt.s); got != tt.want { + t.Errorf("ParseIPOrCIDR(%q) = %v, want %v", tt.s, got, tt.want) + } + }) + } +} diff --git a/internal/httpctx/context_keys.go b/internal/httpctx/context_keys.go index 5d9fa59e..615eb15e 100644 --- a/internal/httpctx/context_keys.go +++ b/internal/httpctx/context_keys.go @@ -14,7 +14,9 @@ package httpctx -import "github.com/gofiber/fiber/v3" +import ( + "github.com/gofiber/fiber/v3" +) // ContextKey names a request-local value stored in fiber.Ctx locals. type ContextKey string @@ -38,6 +40,7 @@ const ( ContextKeyHostID ContextKey = "host-id" ContextKeyWebsiteConfig ContextKey = "website-config" ContextKeyCallerIdentity ContextKey = "iam-caller-identity" + ContextKeyOriginalURIPath ContextKey = "original-uri-path" ) func (ck ContextKey) Set(ctx fiber.Ctx, val any) { diff --git a/internal/netutil/clientcert.go b/internal/netutil/clientcert.go new file mode 100644 index 00000000..3017c0e1 --- /dev/null +++ b/internal/netutil/clientcert.go @@ -0,0 +1,49 @@ +// 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" + "crypto/x509" + "fmt" + "os" +) + +// LoadClientCert loads a client certificate/key pair for presenting on an +// outbound mTLS connection +func LoadClientCert(certFile, keyFile string) (tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return tls.Certificate{}, fmt.Errorf("load client certificate: %w", err) + } + return cert, nil +} + +// LoadCACertPool loads a PEM-encoded CA bundle for verifying a peer's +// certificate on an outbound connection (the server's cert, from a +// client's perspective) or, on an inbound mTLS listener, a connecting +// client's certificate. +func LoadCACertPool(caFile string) (*x509.CertPool, error) { + pemBytes, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read CA certificate %q: %w", caFile, err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("no valid certificates found in %q", caFile) + } + + return pool, nil +} diff --git a/internal/netutil/multi_listener.go b/internal/netutil/multi_listener.go index 7affd504..5a3ec444 100644 --- a/internal/netutil/multi_listener.go +++ b/internal/netutil/multi_listener.go @@ -16,6 +16,7 @@ package netutil import ( "crypto/tls" + "crypto/x509" "errors" "fmt" "net" @@ -270,10 +271,38 @@ func NewMultiAddrListener(network, address string, opts ListenerOptions) (net.Li return NewMultiListener(listeners...), nil } +// TLSOptions configures the server-side tls.Config for +// NewMultiAddrTLSListenerWithOptions. A non-nil ClientCAs enables mTLS: +// inbound connections must present a certificate verified against that +// pool +type TLSOptions struct { + GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) + ClientCAs *x509.CertPool + RequireClientCert bool +} + +// NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the +// hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS. func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error), opts ListenerOptions) (net.Listener, error) { + return NewMultiAddrTLSListenerWithOptions(network, address, TLSOptions{GetCertificate: getCertificateFunc}, opts) +} + +// NewMultiAddrTLSListenerWithOptions is NewMultiAddrTLSListener with control +// over client-certificate verification (mTLS), for listeners — such as the +// standalone IAM service's private endpoints — that must authenticate the +// connecting client, not just the server. +func NewMultiAddrTLSListenerWithOptions(network, address string, tlsOpts TLSOptions, opts ListenerOptions) (net.Listener, error) { config := &tls.Config{ MinVersion: tls.VersionTLS12, - GetCertificate: getCertificateFunc, + GetCertificate: tlsOpts.GetCertificate, + } + if tlsOpts.ClientCAs != nil { + config.ClientCAs = tlsOpts.ClientCAs + if tlsOpts.RequireClientCert { + config.ClientAuth = tls.RequireAndVerifyClientCert + } else { + config.ClientAuth = tls.VerifyClientCertIfGiven + } } if IsUnixSocketPath(address) { @@ -314,3 +343,18 @@ func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*t return NewMultiListener(listeners...), nil } + +// RequireSecureTransport enforces mTLS or unix socket a unix socket +// is always acceptable (the filesystem is the trust boundary), +// but a TCP address is only acceptable when mTLS (a server cert plus +// mandatory client-certificate verification) is actually configured for +// it +func RequireSecureTransport(address string, hasMTLS bool) error { + if IsUnixSocketPath(address) { + return nil + } + if !hasMTLS { + return fmt.Errorf("private listener %q requires either a unix socket path or mTLS (server cert + client CA); refusing to serve on plain TCP", address) + } + return nil +} diff --git a/s3api/utils/multi_listener_test.go b/internal/netutil/multi_listener_full_test.go similarity index 97% rename from s3api/utils/multi_listener_test.go rename to internal/netutil/multi_listener_full_test.go index 6e7b000b..dae68766 100644 --- a/s3api/utils/multi_listener_test.go +++ b/internal/netutil/multi_listener_full_test.go @@ -12,7 +12,7 @@ // specific language governing permissions and limitations // under the License. -package utils +package netutil import ( "crypto/tls" @@ -349,7 +349,7 @@ func TestNewMultiAddrListener(t *testing.T) { func TestNewMultiAddrTLSListener(t *testing.T) { // Create a simple test certificate getCertFunc := func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := tls.X509KeyPair([]byte(testCert), []byte(testKey)) + cert, err := tls.X509KeyPair([]byte(multiListenerTestCert), []byte(multiListenerTestKey)) return &cert, err } @@ -388,7 +388,7 @@ func TestNewMultiAddrTLSListener(t *testing.T) { } // Test certificate and key for TLS tests -const testCert = `-----BEGIN CERTIFICATE----- +const multiListenerTestCert = `-----BEGIN CERTIFICATE----- MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d @@ -400,7 +400,7 @@ Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc 6MF9+Yw1Yy0t -----END CERTIFICATE-----` -const testKey = `-----BEGIN EC PRIVATE KEY----- +const multiListenerTestKey = `-----BEGIN EC PRIVATE KEY----- MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== diff --git a/internal/netutil/multi_listener_test.go b/internal/netutil/multi_listener_test.go new file mode 100644 index 00000000..cc9b2952 --- /dev/null +++ b/internal/netutil/multi_listener_test.go @@ -0,0 +1,200 @@ +// 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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "testing" + "time" +) + +func TestRequireSecureTransport(t *testing.T) { + tests := []struct { + name string + address string + hasMTLS bool + wantErr bool + }{ + {name: "unix socket without mTLS is fine", address: "/tmp/private.sock", hasMTLS: false, wantErr: false}, + {name: "unix socket with mTLS is fine", address: "/tmp/private.sock", hasMTLS: true, wantErr: false}, + {name: "abstract socket without mTLS is fine", address: "@private", hasMTLS: false, wantErr: false}, + {name: "TCP without mTLS is rejected", address: "127.0.0.1:9443", hasMTLS: false, wantErr: true}, + {name: "TCP with mTLS is fine", address: "127.0.0.1:9443", hasMTLS: true, wantErr: false}, + {name: "bare port without mTLS is rejected", address: ":9443", hasMTLS: false, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := RequireSecureTransport(tt.address, tt.hasMTLS) + if (err != nil) != tt.wantErr { + t.Errorf("RequireSecureTransport(%q, %v) error = %v, wantErr %v", tt.address, tt.hasMTLS, err, tt.wantErr) + } + }) + } +} + +// TestMTLSListenerRejectsClientWithoutCert confirms a listener built with +// TLSOptions{ClientCAs, RequireClientCert: true} — the shape the private +// IAM endpoints use — refuses a TLS handshake from a client presenting no +// certificate, and accepts one presenting a cert signed by the configured +// CA. This is the core security boundary the whole standalone-IAM-service +// design leans on ("otherwise this endpoint should not serve anything"), +// so it's worth verifying the handshake itself, not just the config shape. +func TestMTLSListenerRejectsClientWithoutCert(t *testing.T) { + ca := generateTestCA(t) + serverCert := issueTestCert(t, ca, "server") + clientCert := issueTestCert(t, ca, "client") + + caPool := x509.NewCertPool() + caPool.AddCert(ca.cert) + + addr := "127.0.0.1:0" + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("listen: %v", err) + } + // Pinned to TLS 1.2: TLS 1.3 clients can report Dial as successful before + // observing the server's post-handshake rejection alert for a missing + // client cert, making that half of this test flaky. TLS 1.2 client-cert + // verification is synchronous within the initial handshake flight. + tlsLn := tls.NewListener(ln, &tls.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{serverCert}, + ClientCAs: caPool, + ClientAuth: tls.RequireAndVerifyClientCert, + }) + defer tlsLn.Close() + + serverErrCh := make(chan error, 1) + go func() { + conn, err := tlsLn.Accept() + if err != nil { + serverErrCh <- err + return + } + defer conn.Close() + serverErrCh <- conn.(*tls.Conn).Handshake() + }() + + t.Run("no client cert is rejected", func(t *testing.T) { + conn, err := tls.Dial("tcp", tlsLn.Addr().String(), &tls.Config{ + RootCAs: caPool, + MaxVersion: tls.VersionTLS12, + ServerName: "server", + }) + if err == nil { + conn.Close() + t.Fatal("expected handshake to fail without a client certificate") + } + <-serverErrCh + }) + + go func() { + conn, err := tlsLn.Accept() + if err != nil { + serverErrCh <- err + return + } + defer conn.Close() + serverErrCh <- conn.(*tls.Conn).Handshake() + }() + + t.Run("valid client cert is accepted", func(t *testing.T) { + conn, err := tls.Dial("tcp", tlsLn.Addr().String(), &tls.Config{ + RootCAs: caPool, + MaxVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{clientCert}, + ServerName: "server", + }) + if err != nil { + t.Fatalf("expected handshake to succeed with a valid client certificate: %v", err) + } + conn.Close() + if err := <-serverErrCh; err != nil { + t.Fatalf("server-side handshake failed: %v", err) + } + }) +} + +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +func generateTestCA(t *testing.T) testCA { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate CA key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create CA cert: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse CA cert: %v", err) + } + + return testCA{cert: cert, key: key} +} + +func issueTestCert(t *testing.T, ca testCA, cn string) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate %s key: %v", cn, err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: cn}, + DNSNames: []string{cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + t.Fatalf("create %s cert: %v", cn, err) + } + + return tls.Certificate{ + Certificate: [][]byte{der}, + PrivateKey: key, + } +} diff --git a/internal/sigv4auth/auth.go b/internal/sigv4auth/auth.go index 92dd4120..e57a16d9 100644 --- a/internal/sigv4auth/auth.go +++ b/internal/sigv4auth/auth.go @@ -35,8 +35,19 @@ const ( // HeaderSecurityToken is the header a temporary credential's // SessionToken is presented in, matching AWS's X-Amz-Security-Token. HeaderSecurityToken = "X-Amz-Security-Token" + + // TempAccessKeyIDPrefix marks temporary credentials minted by + // AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that + // distinguishes them from long-term AKIA… access keys. + TempAccessKeyIDPrefix = "ASIA" ) +// IsTempAccessKeyID reports whether accessKeyID is a temporary (session) +// credential rather than a long-term AKIA… access key. +func IsTempAccessKeyID(accessKeyID string) bool { + return strings.HasPrefix(accessKeyID, TempAccessKeyIDPrefix) +} + type ParseErrorKind string const ( diff --git a/internal/sigv4auth/canonical.go b/internal/sigv4auth/canonical.go new file mode 100644 index 00000000..835704d8 --- /dev/null +++ b/internal/sigv4auth/canonical.go @@ -0,0 +1,376 @@ +// 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" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +const ( + amzAlgorithmKey = "X-Amz-Algorithm" + amzDateKey = "X-Amz-Date" + amzCredentialKey = "X-Amz-Credential" + amzSignedHeadersKey = "X-Amz-SignedHeaders" + + authorizationHeader = "Authorization" + + hostHeader = "host" + contentLengthHeader = "content-length" +) + +// BuildCredentialScope builds the "yyyymmdd/region/service/aws4_request" +// scope string shared by the credential, the string-to-sign, and (as the +// derivation input) DeriveKey. +func BuildCredentialScope(yyyymmdd, region, service string) string { + return strings.Join([]string{yyyymmdd, region, service, Terminal}, "/") +} + +// SigningInput is everything BuildAndSign needs to reproduce a request's +// SigV4 canonical request, string-to-sign, and signature. +type SigningInput struct { + Method string + Host string + URIPath string // raw/escaped request path; "" is treated as "/" + Query url.Values + Header http.Header + ContentLength int64 + AccessKeyID string + CredentialScope string // BuildCredentialScope(yyyymmdd, region, service) + SignedHdrs []string + PayloadHash string + SigningTime time.Time + DisableURIPathEscaping bool + IsPreSign bool +} + +// SignResult carries everything BuildAndSign computed: the canonical +// request and string-to-sign (for a caller to compare against a +// client-presented signature), the signature itself, and — since +// BuildAndSign never mutates its input — every value that needs to land +// back on the wire for an outbound request to actually be signed. +// AmzDate is the formatted X-Amz-Date value used in canonicalization; +// for header auth (!IsPreSign) a caller signing a real outbound request +// must set it as a header itself (req.Header.Set("X-Amz-Date", ...)) — +// unlike a verification caller, which only ever compares Signature and +// never sends the request anywhere. RawQuery is the sorted, re-encoded +// query string in both modes when SigningInput.IsPreSign, it additionally +// carries the appended "&X-Amz-Signature=..." that makes it the final +// presigned query string — for presign, X-Amz-Date already went into +// RawQuery, so AmzDate itself needs no separate application. +// AuthorizationHeader (header-auth's full Authorization value) is only +// populated when !IsPreSign. +type SignResult struct { + SignedHeaders http.Header + CanonicalString string + StringToSign string + Signature string + AmzDate string + RawQuery string + AuthorizationHeader string +} + +// BuildAndSign rebuilds the canonical request for in the same way a SigV4 +// client would have, and signs it with derivedKey — the kSigning value +// DeriveKey computes from a secret, or the equivalent value a standalone +// IAM service returns without ever revealing that secret. It never mutates +// in.Query or in.Header: verifying a request's signature must not corrupt +// the real inbound query/headers a caller still needs afterward, and +// signing a real outbound request works just as well by having the caller +// apply SignResult's output themselves. The caller compares Signature +// against the one presented on the original request; a match proves the +// request was signed by whoever holds the secret DeriveKey (or the remote +// IAM service) derived derivedKey from. +func BuildAndSign(derivedKey []byte, in SigningInput) *SignResult { + query := cloneQuery(in.Query) + headers := cloneHeader(in.Header) + + amzDate := in.SigningTime.Format(ISO8601Format) + setRequiredSigningFields(headers, query, in.IsPreSign, amzDate) + + for key := range query { + sort.Strings(query[key]) + } + + credentialStr := in.AccessKeyID + "/" + in.CredentialScope + if in.IsPreSign { + query.Set(amzCredentialKey, credentialStr) + } + + unsignedHeaders := headers + if in.IsPreSign { + var hoisted url.Values + hoisted, unsignedHeaders = hoistHeadersToQuery(headers) + for k := range hoisted { + query[k] = hoisted[k] + } + } + + signedHeaders, signedHeadersStr, canonicalHeaderStr := buildCanonicalHeaders(in.Host, in.SignedHdrs, unsignedHeaders, in.ContentLength) + + if in.IsPreSign { + query.Set(amzSignedHeadersKey, signedHeadersStr) + } + + var rawQuery strings.Builder + rawQuery.WriteString(strings.ReplaceAll(query.Encode(), "+", "%20")) + + canonicalURI := in.URIPath + if canonicalURI == "" { + canonicalURI = "/" + } + if !in.DisableURIPathEscaping { + canonicalURI = escapePath(canonicalURI, false) + } + + canonicalString := buildCanonicalString(in.Method, canonicalURI, rawQuery.String(), signedHeadersStr, canonicalHeaderStr, in.PayloadHash) + strToSign := buildStringToSign(in.SigningTime, in.CredentialScope, canonicalString) + signature := hex.EncodeToString(hmacSHA256(derivedKey, []byte(strToSign))) + + result := &SignResult{ + SignedHeaders: signedHeaders, + CanonicalString: canonicalString, + StringToSign: strToSign, + Signature: signature, + AmzDate: amzDate, + RawQuery: rawQuery.String(), + } + + if in.IsPreSign { + result.RawQuery += "&X-Amz-Signature=" + signature + } else { + result.AuthorizationHeader = buildAuthorizationHeader(credentialStr, signedHeadersStr, signature) + } + + return result +} + +func cloneQuery(q url.Values) url.Values { + clone := make(url.Values, len(q)) + for k, v := range q { + clone[k] = append([]string(nil), v...) + } + return clone +} + +func cloneHeader(h http.Header) http.Header { + clone := make(http.Header, len(h)) + for k, v := range h { + clone[k] = append([]string(nil), v...) + } + return clone +} + +func setRequiredSigningFields(headers http.Header, query url.Values, isPreSign bool, amzDate string) { + if isPreSign { + query.Set(amzAlgorithmKey, AlgorithmHMACSHA256) + query.Set(amzDateKey, amzDate) + return + } + headers[amzDateKey] = append(headers[amzDateKey][:0], amzDate) +} + +// hoistHeadersToQuery splits header into the subset eligible for +// query-string hoisting on a presigned request (allowedQueryHoisting) and +// the remainder, which stays as headers. +func hoistHeadersToQuery(header http.Header) (url.Values, http.Header) { + query := url.Values{} + unsignedHeaders := http.Header{} + for k, h := range header { + if allowedQueryHoisting.IsValid(k) { + query[k] = h + } else { + unsignedHeaders[k] = h + } + } + return query, unsignedHeaders +} + +func buildCanonicalHeaders(host string, signedHdrs []string, header http.Header, contentLength int64) (signed http.Header, signedHeadersStr, canonicalHeadersStr string) { + signed = make(http.Header) + + var headerNames []string + headerNames = append(headerNames, hostHeader) + signed[hostHeader] = append(signed[hostHeader], host) + + if slices.Contains(signedHdrs, contentLengthHeader) { + headerNames = append(headerNames, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(contentLength, 10)) + } + + for k, v := range header { + if !shouldSignHeader(k, signedHdrs) { + continue + } + if strings.EqualFold(k, contentLengthHeader) { + // prevent signing the already-handled content-length header. + continue + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headerNames = append(headerNames, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headerNames) + + signedHeadersStr = strings.Join(headerNames, ";") + + var canonicalHeaders strings.Builder + for _, name := range headerNames { + if name == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteByte(':') + canonicalHeaders.WriteString(stripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(name) + canonicalHeaders.WriteByte(':') + values := signed[name] + for j, v := range values { + canonicalHeaders.WriteString(strings.TrimSpace(stripExcessSpaces(v))) + if j < len(values)-1 { + canonicalHeaders.WriteByte(',') + } + } + } + canonicalHeaders.WriteByte('\n') + } + + return signed, signedHeadersStr, canonicalHeaders.String() +} + +// shouldSignHeader reports whether header must be included in the +// canonical headers: never Authorization itself, otherwise exactly the +// headers named in signedHdrs (the client's own SignedHeaders list) when +// non-nil, else falling back to ignoredHeaders' default policy. +func shouldSignHeader(header string, signedHdrs []string) bool { + if strings.EqualFold(header, authorizationHeader) { + return false + } + if signedHdrs != nil { + return slices.ContainsFunc(signedHdrs, func(signedHeader string) bool { + return strings.EqualFold(signedHeader, header) + }) + } + return ignoredHeaders.IsValid(header) +} + +func buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders, payloadHash string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + payloadHash, + }, "\n") +} + +func buildStringToSign(t time.Time, credentialScope, canonicalRequestString string) string { + hash := sha256.Sum256([]byte(canonicalRequestString)) + return strings.Join([]string{ + AlgorithmHMACSHA256, + t.Format(ISO8601Format), + credentialScope, + hex.EncodeToString(hash[:]), + }, "\n") +} + +func buildAuthorizationHeader(credentialStr, signedHeadersStr, signature string) string { + return AlgorithmHMACSHA256 + " Credential=" + credentialStr + + ", SignedHeaders=" + signedHeadersStr + ", Signature=" + signature +} + +const doubleSpace = " " + +// stripExcessSpaces rewrites str to collapse any run of interior spaces to +// a single space, after trimming leading/trailing spaces. +func stripExcessSpaces(str string) string { + var j, k, l, m, spaces int + for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { + } + for k = 0; k < j && str[k] == ' '; k++ { + } + str = str[k : j+1] + + j = strings.Index(str, doubleSpace) + if j < 0 { + return str + } + + buf := []byte(str) + for k, m, l = j, j, len(buf); k < l; k++ { + if buf[k] == ' ' { + if spaces == 0 { + buf[m] = buf[k] + m++ + } + spaces++ + } else { + spaces = 0 + buf[m] = buf[k] + m++ + } + } + + return string(buf[:m]) +} + +// escapePath URI-encodes path per SigV4's canonical-URI rules: every byte +// except unreserved characters (A-Za-z0-9-._~) is percent-encoded, and '/' +// is additionally preserved unless encodeSep is set (used for the path +// itself, never encodeSep; AWS also reuses this style of encoding for query +// keys/values, always with encodeSep). +func escapePath(path string, encodeSep bool) string { + var buf strings.Builder + buf.Grow(len(path)) + for i := 0; i < len(path); i++ { + c := path[i] + if isUnreservedByte(c) || (c == '/' && !encodeSep) { + buf.WriteByte(c) + continue + } + buf.WriteByte('%') + buf.WriteByte(upperHex[c>>4]) + buf.WriteByte(upperHex[c&0x0f]) + } + return buf.String() +} + +const upperHex = "0123456789ABCDEF" + +func isUnreservedByte(c byte) bool { + switch { + case 'A' <= c && c <= 'Z': + return true + case 'a' <= c && c <= 'z': + return true + case '0' <= c && c <= '9': + return true + case c == '-' || c == '_' || c == '.' || c == '~': + return true + } + return false +} diff --git a/internal/sigv4auth/canonical_test.go b/internal/sigv4auth/canonical_test.go new file mode 100644 index 00000000..d306a239 --- /dev/null +++ b/internal/sigv4auth/canonical_test.go @@ -0,0 +1,151 @@ +// 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 ( + "net/http" + "strings" + "testing" + "time" +) + +// TestBuildCanonicalHeaders ports the canonical-header edge cases (leading/ +// trailing/interior space stripping, multi-value merging, sort order) +func TestBuildCanonicalHeaders(t *testing.T) { + req, err := http.NewRequest("POST", "https://mockAPI.mock-region.amazonaws.com", nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + + req.Header.Set("FooInnerSpace", " inner space ") + req.Header.Set("FooLeadingSpace", " leading-space") + req.Header.Add("FooMultipleSpace", "no-space") + req.Header.Add("FooMultipleSpace", "\ttab-space") + req.Header.Add("FooMultipleSpace", "trailing-space ") + req.Header.Set("FooNoSpace", "no-space") + req.Header.Set("FooTabSpace", "\ttab-space\t") + req.Header.Set("FooTrailingSpace", "trailing-space ") + req.Header.Set("FooWrappedSpace", " wrapped-space ") + + signingTime := time.Date(2021, 10, 20, 12, 42, 0, 0, time.UTC) + yyyymmdd := signingTime.Format(YYYYMMDD) + in := SigningInputFromRequest(req) + in.AccessKeyID = "AKID" + in.CredentialScope = BuildCredentialScope(yyyymmdd, "mock-region", "mockAPI") + in.SigningTime = signingTime + result := BuildAndSign([]byte("dummy-derived-key"), in) + + expectCanonicalString := strings.Join([]string{ + `POST`, + `/`, + ``, + `fooinnerspace:inner space`, + `fooleadingspace:leading-space`, + `foomultiplespace:no-space,tab-space,trailing-space`, + `foonospace:no-space`, + `footabspace:tab-space`, + `footrailingspace:trailing-space`, + `foowrappedspace:wrapped-space`, + `host:mockAPI.mock-region.amazonaws.com`, + `x-amz-date:20211020T124200Z`, + ``, + `fooinnerspace;fooleadingspace;foomultiplespace;foonospace;footabspace;footrailingspace;foowrappedspace;host;x-amz-date`, + ``, + }, "\n") + + if result.CanonicalString != expectCanonicalString { + t.Errorf("canonical string mismatch:\ngot:\n%s\nwant:\n%s", result.CanonicalString, expectCanonicalString) + } +} + +func TestBuildAndSignOpaqueURLAndQuerySorting(t *testing.T) { + req, err := http.NewRequest("POST", "https://dynamodb.us-east-1.amazonaws.com", nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + req.URL.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + + in := SigningInputFromRequest(req) + if want := "/bucket/key-._~,!@#$%^&*()"; in.URIPath != want { + t.Errorf("URIPath = %q, want %q (the pre-escaped Opaque path used verbatim, not re-derived from URL.Path)", in.URIPath, want) + } + + signingTime := time.Unix(0, 0) + yyyymmdd := signingTime.Format(YYYYMMDD) + in.AccessKeyID = "AKID" + in.CredentialScope = BuildCredentialScope(yyyymmdd, "us-east-1", "dynamodb") + in.SigningTime = signingTime + result := BuildAndSign([]byte("dummy-derived-key"), in) + + expected := "Foo=a&Foo=m&Foo=o&Foo=z" + if result.RawQuery != expected { + t.Errorf("RawQuery = %q, want %q", result.RawQuery, expected) + } +} + +// TestSanitizeHostForHeader confirms a request's explicit Host takes +// precedence over URL.Host and is reflected verbatim in the canonical +// "host" header +func TestSanitizeHostForHeader(t *testing.T) { + req, err := http.NewRequest("POST", "https://dynamodb.us-east-1.amazonaws.com", nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + req.Host = "myhost" + + in := SigningInputFromRequest(req) + signingTime := time.Now() + yyyymmdd := signingTime.Format(YYYYMMDD) + in.AccessKeyID = "AKID" + in.CredentialScope = BuildCredentialScope(yyyymmdd, "us-east-1", "dynamodb") + in.SigningTime = signingTime + result := BuildAndSign([]byte("dummy-derived-key"), in) + + if !strings.Contains(result.CanonicalString, "host:"+req.Host) { + t.Errorf("canonical host header invalid:\n%s", result.CanonicalString) + } +} + +// TestBuildAndSignExplicitSignedHeadersIgnoresUnsignedHeaders confirms that, +// with an explicit SignedHdrs list, extra headers present on the request +// but absent from that list never affect the resulting signature. +func TestBuildAndSignExplicitSignedHeadersIgnoresUnsignedHeaders(t *testing.T) { + build := func(extraHeaders bool) string { + req, err := http.NewRequest("POST", "https://dynamodb.us-east-1.amazonaws.com", nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + if extraHeaders { + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("X-Unsigned-Header", "ignored") + } + + signingTime := time.Unix(0, 0) + yyyymmdd := signingTime.Format(YYYYMMDD) + derivedKey := DeriveKey("SECRET", yyyymmdd, "us-east-1", "dynamodb") + + in := SigningInputFromRequest(req) + in.AccessKeyID = "AKID" + in.CredentialScope = BuildCredentialScope(yyyymmdd, "us-east-1", "dynamodb") + in.SignedHdrs = []string{"host", "x-amz-date"} + in.SigningTime = signingTime + result := BuildAndSign(derivedKey, in) + return result.Signature + } + + if got, want := build(false), build(true); got != want { + t.Errorf("unsigned headers changed the signature: %q != %q", got, want) + } +} diff --git a/internal/sigv4auth/ctx.go b/internal/sigv4auth/ctx.go new file mode 100644 index 00000000..347d6b79 --- /dev/null +++ b/internal/sigv4auth/ctx.go @@ -0,0 +1,144 @@ +// 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 ( + "net/http" + "net/url" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/httpctx" +) + +// signingInputFromCtx builds a SigningInput straight from ctx's underlying +// fasthttp request. isPreSign selects between header-auth's and presigned (query) auth's +// slightly different query/header handling; see queryFromCtx/ +// presignQueryFromCtx and headersFromCtx. +func signingInputFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64, requiredSignedHdrs []string, isPreSign bool) (SigningInput, error) { + if err := validateRequiredSignedHeaders(signedHdrs, requiredSignedHdrs); err != nil { + return SigningInput{}, err + } + + headers, err := headersFromCtx(ctx, signedHdrs, requiredSignedHdrs, isPreSign) + if err != nil { + return SigningInput{}, err + } + + query := queryFromCtx(ctx) + if isPreSign { + query = presignQueryFromCtx(ctx) + } + + return SigningInput{ + Method: methodFromCtx(ctx), + Host: hostFromCtx(ctx), + URIPath: uriPathFromCtx(ctx), + Query: query, + Header: headers, + ContentLength: contentLength, + IsPreSign: isPreSign, + }, nil +} + +// methodFromCtx returns ctx's HTTP method from the underlying fasthttp +// request header directly +func methodFromCtx(ctx fiber.Ctx) string { + return string(ctx.Request().Header.Method()) +} + +// headersFromCtx collects ctx's request headers eligible for signing: every +// header naming itself in signedHdrs, plus any header ignoredHeaders always +// signs regardless. Reads straight off the underlying fasthttp request +// (Header.All(), preserving every duplicate key exactly as sent). +func headersFromCtx(ctx fiber.Ctx, signedHdrs, requiredSignedHdrs []string, isPreSign bool) (http.Header, error) { + headers := http.Header{} + headersNotSigned := []string{} + for key, value := range ctx.Request().Header.All() { + keyStr := string(key) + if includeHeader(keyStr, signedHdrs) || IsIgnoredHeader(keyStr) { + headers.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 nil, &HeadersNotSignedError{Headers: headersNotSigned} + } + + if !isPreSign { + for _, header := range signedHdrs { + if headers.Get(header) == "" { + headers.Set(header, "") + } + } + } + + return headers, nil +} + +// queryFromCtx returns ctx's full, unfiltered query string as url.Values — +// the header-auth path, where any existing query parameters (e.g. +// ?partNumber=2) are simply part of the canonical request, untouched. +func queryFromCtx(ctx fiber.Ctx) url.Values { + query := url.Values{} + for key, value := range ctx.Request().URI().QueryArgs().All() { + query.Add(string(key), string(value)) + } + return query +} + +// presignQueryFromCtx returns ctx's query string as url.Values with the +// generated SigV4 auth parameters excluded (generatedQueryAuthParams) — +// the presign path, which must recompute and re-add its own +// X-Amz-Credential/X-Amz-SignedHeaders/X-Amz-Signature rather than sign the +// client-presented ones. +func presignQueryFromCtx(ctx fiber.Ctx) url.Values { + query := url.Values{} + for key, value := range ctx.Request().URI().QueryArgs().All() { + keyStr := string(key) + if _, ok := generatedQueryAuthParams[keyStr]; ok { + continue + } + query.Add(keyStr, string(value)) + } + return query +} + +// uriPathFromCtx returns ctx's raw request path exactly as received on the +// wire (fasthttp's PathOriginal — unnormalized, unescaped-or-not exactly as +// sent, no dot-segment collapsing), "/" if empty. HostStyleParser rewrites +// PathOriginal itself to move a virtual-hosted-style request's bucket from +// the Host header into the path for routing, so the true original is read +// back from where it stashed it rather than from PathOriginal directly. +func uriPathFromCtx(ctx fiber.Ctx) string { + path := string(ctx.Request().URI().PathOriginal()) + if httpctx.ContextKeyOriginalURIPath.IsSet(ctx) { + path, _ = httpctx.ContextKeyOriginalURIPath.Get(ctx).(string) + } + if path == "" { + return "/" + } + return path +} + +// hostFromCtx returns ctx's Host header verbatim. +func hostFromCtx(ctx fiber.Ctx) string { + return string(ctx.Request().Header.Host()) +} diff --git a/internal/sigv4auth/derive.go b/internal/sigv4auth/derive.go new file mode 100644 index 00000000..1ae8105a --- /dev/null +++ b/internal/sigv4auth/derive.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 sigv4auth + +import ( + "crypto/hmac" + "crypto/sha256" +) + +// DeriveKey computes the SigV4 signing key (kSigning) for a secret access +// key and a request's credential scope: +// +// kDate = HMAC-SHA256("AWS4"+secret, yyyymmdd) +// kRegion = HMAC-SHA256(kDate, region) +// kService = HMAC-SHA256(kRegion, service) +// kSigning = HMAC-SHA256(kService, "aws4_request") +// +// This is the one artifact that's safe to hand across a process boundary: a +// standalone IAM service can compute and return it without ever exposing +// the secret itself. Every SigV4 consumer in this codebase (header auth, +// presigned/query auth, POST-policy, chunked upload) is built on top of this +// single implementation rather than each deriving its own key. +func DeriveKey(secret, yyyymmdd, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), []byte(yyyymmdd)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + return hmacSHA256(kService, []byte(Terminal)) +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} diff --git a/internal/sigv4auth/derive_test.go b/internal/sigv4auth/derive_test.go new file mode 100644 index 00000000..8b3f5afa --- /dev/null +++ b/internal/sigv4auth/derive_test.go @@ -0,0 +1,34 @@ +// 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 ( + "encoding/hex" + "testing" +) + +func TestDeriveKey(t *testing.T) { + const wantHex = "2c94c0cf5378ada6887f09bb697df8fc0affdb34ba1cdd5bda32b664bd55b73c" + + got := DeriveKey("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "20150830", "us-east-1", "iam") + + want, err := hex.DecodeString(wantHex) + if err != nil { + t.Fatalf("decode want hex: %v", err) + } + + if hex.EncodeToString(got) != hex.EncodeToString(want) { + t.Errorf("DeriveKey() = %x, want %x", got, want) + } +} diff --git a/internal/sigv4auth/header_rules.go b/internal/sigv4auth/header_rules.go new file mode 100644 index 00000000..51801b18 --- /dev/null +++ b/internal/sigv4auth/header_rules.go @@ -0,0 +1,129 @@ +// 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 "strings" + +// rule reports whether a header name adheres to some signing policy — which +// headers are excluded from signing, which must be signed when present, and +// which are eligible for query-string hoisting on a presigned request. +type rule interface { + IsValid(value string) bool +} + +// rules is a set of rule; IsValid reports whether any rule in the set +// matches (nested/composable rules). +type rules []rule + +func (r rules) IsValid(value string) bool { + for _, rl := range r { + if rl.IsValid(value) { + return true + } + } + return false +} + +// mapRule is a case-insensitive set-membership rule. +type mapRule map[string]struct{} + +func (m mapRule) IsValid(value string) bool { + for key := range m { + if strings.EqualFold(key, value) { + return true + } + } + return false +} + +// allowList and excludeList wrap another rule, only for readability at the +// table-definition call site — allowList is a no-op wrapper, excludeList +// inverts. +type allowList struct{ rule } + +func (w allowList) IsValid(value string) bool { return w.rule.IsValid(value) } + +type excludeList struct{ rule } + +func (b excludeList) IsValid(value string) bool { return !b.rule.IsValid(value) } + +// patterns matches by case-insensitive prefix. +type patterns []string + +func (p patterns) IsValid(value string) bool { + for _, pattern := range p { + if hasPrefixFold(value, pattern) { + return true + } + } + return false +} + +// inclusiveRules requires every rule in the set to match. +type inclusiveRules []rule + +func (r inclusiveRules) IsValid(value string) bool { + for _, rl := range r { + if !rl.IsValid(value) { + return false + } + } + return true +} + +func hasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) +} + +// ignoredHeaders is excluded from signing regardless of SignedHeaders. +var ignoredHeaders = rules{ + excludeList{ + mapRule{ + "Authorization": struct{}{}, + "User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + "Expect": struct{}{}, + "Transfer-Encoding": struct{}{}, + }, + }, +} + +// requiredSignedHeadersRule is the header-auth SignedHeaders policy: which +// request headers, if present, must appear in SignedHeaders. +var requiredSignedHeadersRule = rules{ + allowList{ + mapRule{ + "Host": struct{}{}, + }, + }, + patterns{"X-Amz-"}, +} + +// allowedQueryHoisting selects which unsigned headers a presigned request +// may hoist into the query string. +var allowedQueryHoisting = inclusiveRules{ + excludeList{requiredSignedHeadersRule}, + patterns{"X-Amz-"}, +} + +// IsIgnoredHeader reports whether a header is normally excluded from signing. +func IsIgnoredHeader(header string) bool { + return !ignoredHeaders.IsValid(header) +} + +// IsRequiredSignedHeader reports whether a header must be signed when it is +// present on an incoming request. +func IsRequiredSignedHeader(header string) bool { + return requiredSignedHeadersRule.IsValid(header) +} diff --git a/internal/sigv4auth/query.go b/internal/sigv4auth/query.go index 6ad04c6c..176f9932 100644 --- a/internal/sigv4auth/query.go +++ b/internal/sigv4auth/query.go @@ -14,19 +14,12 @@ 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" ) @@ -127,6 +120,15 @@ func ParseQueryAuthorization(ctx fiber.Ctx, opts QueryAuthOptions) (AuthData, Qu return a, details, err } + // A security token in the query string is only ever legitimate alongside + // a temporary (ASIA…) access key. Reject it outright for root or any + // long-term (AKIA…) credential before any signature work, rather than + // letting it fall through to a signature mismatch once the tampered or + // unsigned token parameter invalidates the canonical query string. + if ctx.Request().URI().QueryArgs().Has(QuerySecurityToken) && !IsTempAccessKeyID(creds.Access) { + return a, details, &QueryError{Kind: ErrQuerySecurityToken} + } + if opts.Region != "" && creds.Region != opts.Region { return a, details, &QueryError{ Kind: ErrQueryIncorrectRegion, @@ -255,62 +257,59 @@ 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) { +// CheckQuerySignature rebuilds a SigV4 query-auth request — reading +// everything it needs straight from ctx, with no intermediate +// *http.Request — and compares the generated query signature to the +// signature presented by the client. derivedKey is the request's kSigning +// value — either computed locally via DeriveKey from a known secret, or +// obtained from a standalone IAM service that never reveals the secret +// itself. +func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte, 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) + in, err := signingInputFromCtx(ctx, signedHdrs, contentLen, opts.RequiredSignedHeaders, true) if err != nil { return nil, err } + in.AccessKeyID = auth.Access + in.CredentialScope = BuildCredentialScope(tdate.Format(YYYYMMDD), auth.Region, service) + in.SignedHdrs = signedHdrs + in.PayloadHash = payloadHash + in.SigningTime = tdate + in.DisableURIPathEscaping = opts.DisableURIPathEscaping - 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 - // See the identical comment in verify.go's CheckSignature: this - // logger dumps a complete, replayable signed URL (including - // X-Amz-Signature and any session token) unredacted, so it may - // only run at LevelUnsafe. - if debuglogger.IsUnsafeEnabled() { - options.LogSigning = true - options.Logger = logging.NewStandardLogger(os.Stderr) - } - }) - if err != nil { - return nil, fmt.Errorf("presign generated http request: %w", err) + result := BuildAndSign(derivedKey, in) + + // See the identical comment in verify.go's CheckSignature: this dumps a + // complete, replayable signed query string unredacted, so it may only + // run at LevelUnsafe. + if debuglogger.IsUnsafeEnabled() { + debuglogger.Logf("Request Signature:\n"+ + "---[ CANONICAL STRING ]-----------------------------\n%s\n"+ + "---[ STRING TO SIGN ]--------------------------------\n%s\n"+ + "---[ SIGNED QUERY ]-----------------------------------\n%s\n"+ + "-----------------------------------------------------", + result.CanonicalString, result.StringToSign, result.RawQuery) } - urlParts, err := url.Parse(uri) - if err != nil { - return nil, fmt.Errorf("parse presigned url: %w", err) - } - - signature := urlParts.Query().Get(QuerySignature) - if !SecureCompare(signature, auth.Signature) { + if !SecureCompare(result.Signature, auth.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, - StringToSign: signMeta.StringToSign, + StringToSign: result.StringToSign, SignatureProvided: auth.Signature, - StringToSignBytes: HexBytes(signMeta.StringToSign), - CanonicalRequest: signMeta.CanonicalString, - CanonicalRequestBytes: HexBytes(signMeta.CanonicalString), + StringToSignBytes: HexBytes(result.StringToSign), + CanonicalRequest: result.CanonicalString, + CanonicalRequestBytes: HexBytes(result.CanonicalString), } } return &CheckResult{ - CanonicalString: signMeta.CanonicalString, - StringToSign: signMeta.StringToSign, + CanonicalString: result.CanonicalString, + StringToSign: result.StringToSign, }, nil } @@ -322,52 +321,6 @@ var generatedQueryAuthParams = map[string]struct{}{ 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) diff --git a/internal/sigv4auth/request.go b/internal/sigv4auth/request.go new file mode 100644 index 00000000..ec93fff9 --- /dev/null +++ b/internal/sigv4auth/request.go @@ -0,0 +1,112 @@ +// 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 ( + "net/http" + "net/url" + "strings" +) + +// SigningInputFromRequest extracts a SigningInput's request-shaped fields +// (Method, Host, URIPath, Query, Header, ContentLength) from a real +// *http.Request being signed for an outbound call +func SigningInputFromRequest(req *http.Request) SigningInput { + return SigningInput{ + Method: req.Method, + Host: sanitizedHost(req), + URIPath: getURIPath(req.URL), + Query: req.URL.Query(), + Header: req.Header, + ContentLength: req.ContentLength, + } +} + +// sanitizedHost resolves req's effective Host header value (req.Host takes +// precedence over req.URL.Host) and strips a default port (80 for http, 443 +// for https) so the canonical "host" header matches what a well-behaved +// SigV4 client signs. +func sanitizedHost(req *http.Request) string { + host := req.URL.Host + if len(req.Host) > 0 { + host = req.Host + } + port := portOnly(host) + if port != "" && isDefaultPort(req.URL.Scheme, port) { + return stripPort(host) + } + return host +} + +func stripPort(hostport string) string { + before, _, ok := strings.Cut(hostport, ":") + if !ok { + return hostport + } + if before, _, ok := strings.Cut(hostport, "]"); ok { + return strings.TrimPrefix(before, "[") + } + return before +} + +func portOnly(hostport string) string { + _, after, ok := strings.Cut(hostport, ":") + if !ok { + return "" + } + if _, after, ok := strings.Cut(hostport, "]:"); ok { + return after + } + if strings.Contains(hostport, "]") { + return "" + } + return after +} + +func isDefaultPort(scheme, port string) bool { + if port == "" { + return true + } + lowerCaseScheme := strings.ToLower(scheme) + return (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") +} + +// getURIPath returns the escaped URI path component of u, preferring +// u.Opaque (set when the caller pre-escaped the path) over u.EscapedPath(). +func getURIPath(u *url.URL) string { + var uriPath string + + if len(u.Opaque) > 0 { + const schemeSep, pathSep, queryStart = "//", "/", "?" + + opaque := u.Opaque + if idx := strings.Index(opaque, queryStart); idx >= 0 { + opaque = opaque[:idx] + } + if strings.Index(opaque, schemeSep) == 0 { + opaque = opaque[len(schemeSep):] + } + if idx := strings.Index(opaque, pathSep); idx >= 0 { + uriPath = opaque[idx:] + } + } else { + uriPath = u.EscapedPath() + } + + if len(uriPath) == 0 { + uriPath = "/" + } + + return uriPath +} diff --git a/internal/sigv4auth/verify.go b/internal/sigv4auth/verify.go index ca02567d..499511c0 100644 --- a/internal/sigv4auth/verify.go +++ b/internal/sigv4auth/verify.go @@ -14,18 +14,12 @@ 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" ) @@ -64,127 +58,59 @@ func (e *SignatureMismatchError) Error() string { } // CheckSignature rebuilds the canonical request with the supplied service, -// region, payload hash, signing time, and signed headers, then compares the +// 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) { +// derivedKey is the request's kSigning value — either computed +// locally via DeriveKey from a known secret, or obtained from a standalone +// IAM service that never reveals the secret itself. +func CheckSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte, 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) + in, err := signingInputFromCtx(ctx, signedHdrs, contentLen, opts.RequiredSignedHeaders, false) if err != nil { return nil, err } + in.AccessKeyID = auth.Access + in.CredentialScope = BuildCredentialScope(tdate.Format(YYYYMMDD), auth.Region, service) + in.SignedHdrs = signedHdrs + in.PayloadHash = payloadHash + in.SigningTime = tdate + in.DisableURIPathEscaping = opts.DisableURIPathEscaping - signer := v4.NewSigner() + result := BuildAndSign(derivedKey, in) - 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 - // The signer's diagnostic logger prints the canonical request, - // string-to-sign, and (for presigned requests) the complete - // signed URL verbatim, bypassing the redaction layer entirely. - // That's replayable signature/session-token material, so only - // enable it at LevelUnsafe, never at plain debug. - if debuglogger.IsUnsafeEnabled() { - options.LogSigning = true - options.Logger = logging.NewStandardLogger(os.Stderr) - } - }) - if err != nil { - return nil, fmt.Errorf("sign generated http request: %w", err) + // This prints the canonical request and string-to-sign verbatim, + // bypassing the redaction layer entirely — replayable signature + // material, so only ever log it at LevelUnsafe, never at plain debug. + if debuglogger.IsUnsafeEnabled() { + debuglogger.Logf("Request Signature:\n"+ + "---[ CANONICAL STRING ]-----------------------------\n%s\n"+ + "---[ STRING TO SIGN ]--------------------------------\n%s\n"+ + "-----------------------------------------------------", + result.CanonicalString, result.StringToSign) } - genAuth, err := ParseAuthorization(req.Header.Get("Authorization"), service) - if err != nil { - return nil, err - } - - if !SecureCompare(auth.Signature, genAuth.Signature) { + if !SecureCompare(auth.Signature, result.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, - StringToSign: signMeta.StringToSign, + StringToSign: result.StringToSign, SignatureProvided: auth.Signature, - StringToSignBytes: HexBytes(signMeta.StringToSign), - CanonicalRequest: signMeta.CanonicalString, - CanonicalRequestBytes: HexBytes(signMeta.CanonicalString), + StringToSignBytes: HexBytes(result.StringToSign), + CanonicalRequest: result.CanonicalString, + CanonicalRequestBytes: HexBytes(result.CanonicalString), } } return &CheckResult{ - CanonicalString: signMeta.CanonicalString, - StringToSign: signMeta.StringToSign, + CanonicalString: result.CanonicalString, + StringToSign: result.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 @@ -205,7 +131,7 @@ func validateRequiredSignedHeaders(signedHdrs, requiredSignedHdrs []string) erro func isRequiredSignedHeader(header string, requiredSignedHdrs []string) bool { if requiredSignedHdrs == nil { - return v4.IsRequiredSignedHeader(header) + return IsRequiredSignedHeader(header) } return includeHeader(header, requiredSignedHdrs) diff --git a/runoidctests.sh b/runoidctests.sh new file mode 100755 index 00000000..d24ce1bd --- /dev/null +++ b/runoidctests.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Run the test groups that need a real, signed OIDC ID token. +# +# AssumeRoleWithWebIdentity verifies a token's signature against its issuer's +# live JWKS, so these tests need a genuine identity provider rather than a +# fake token. GitHub Actions' own OIDC issuer is the one publicly reachable +# IdP available from inside CI, and only a job holding `id-token: write` can +# mint a token from it — which is why this script lives behind +# .github/workflows/functional-iam-oidc.yml rather than the general +# functional suite. Outside such a job the tests skip themselves, so running +# this locally is harmless but proves little. +# +# It brings up two processes: a standalone IAM service holding every user, +# role, policy and secret, and an s3 gateway that reaches its private +# endpoints over mTLS for signing keys and policy decisions. + +set -Eeuo pipefail + +readonly IAM_PORT=7078 +readonly IAM_PRIVATE_PORT=7079 +readonly GW_PORT=7077 + +readonly IAM_DIR=/tmp/iam-oidc +readonly GW_DIR=/tmp/s3iam-oidc-gw +readonly CERT_DIR=/tmp/s3iam-oidc-certs + +IAM_PID="" +GW_PID="" + +cleanup() { + local status=$? + trap - EXIT + for pid in "$GW_PID" "$IAM_PID"; do + 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 + done + 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" + + 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 +} + +rm -rf "$IAM_DIR" "$GW_DIR" "$CERT_DIR" +mkdir -p "$IAM_DIR" "$GW_DIR" + +# The gateway verifies the IAM service's certificate normally, with no +# hostname override, so the server certificate needs an IP SAN matching the +# address --iam-standalone-endpoint names. +./genmtlscerts.sh "$CERT_DIR" 127.0.0.1 + +echo "Starting the standalone IAM service" +./versitygw --health /healthz -p ":$IAM_PORT" -a user -s pass iam \ + --dir "$IAM_DIR" \ + --private-ports "127.0.0.1:$IAM_PRIVATE_PORT" \ + --private-cert "$CERT_DIR/iam-server.pem" \ + --private-cert-key "$CERT_DIR/iam-server.key" \ + --private-client-ca "$CERT_DIR/ca.pem" & +IAM_PID=$! +wait_for_server "IAM API server" "http://127.0.0.1:$IAM_PORT/healthz" "$IAM_PID" + +echo "Starting the s3 gateway backed by it" +./versitygw --health /healthz -p ":$GW_PORT" -a user -s pass \ + --iam-standalone-endpoint "127.0.0.1:$IAM_PRIVATE_PORT" \ + --iam-standalone-client-cert "$CERT_DIR/gw-client.pem" \ + --iam-standalone-client-cert-key "$CERT_DIR/gw-client.key" \ + --iam-standalone-server-ca "$CERT_DIR/ca.pem" \ + posix "$GW_DIR" & +GW_PID=$! +wait_for_server "s3 gateway" "http://127.0.0.1:$GW_PORT/healthz" "$GW_PID" + +echo "Running the live GitHub OIDC web-identity test" +./versitygw test -a user -s pass -e "http://127.0.0.1:$IAM_PORT" \ + IAMAssumeRoleWithWebIdentity_github_oidc_live + +echo "Running the s3 assumed-role session access control tests" +./versitygw test -a user -s pass \ + -e "http://127.0.0.1:$GW_PORT" \ + --iam-endpoint "http://127.0.0.1:$IAM_PORT" \ + s3-iam-session diff --git a/runtests.sh b/runtests.sh index 7fa11c79..2a1fbee2 100755 --- a/runtests.sh +++ b/runtests.sh @@ -229,6 +229,60 @@ fi # kill off server kill $GW_NO_ACL_PID +ECHO "Running the s3 + standalone IAM access control tests" +# This stage is the only one that runs two versitygw processes at once: a +# standalone IAM service holding every user, policy and secret, and an s3 +# gateway that reaches it over mTLS for signing keys and policy decisions. +# ports: 7080 IAM control plane, 7081 IAM private endpoint, 7082 s3 gateway +rm -rf /tmp/s3iam /tmp/s3iamgw /tmp/s3iamcerts /tmp/s3iam.covdata /tmp/s3iamgw.covdata +mkdir -p /tmp/s3iam /tmp/s3iamgw /tmp/s3iam.covdata /tmp/s3iamgw.covdata + +# The gateway verifies the IAM service's certificate normally, with no +# hostname override, so the server certificate needs an IP SAN matching the +# --iam-standalone-endpoint host. +./genmtlscerts.sh /tmp/s3iamcerts 127.0.0.1 + +GOCOVERDIR=/tmp/s3iam.covdata ./versitygw --health /healthz -p :7080 -a user -s pass iam \ + --dir /tmp/s3iam \ + --private-ports 127.0.0.1:7081 \ + --private-cert /tmp/s3iamcerts/iam-server.pem \ + --private-cert-key /tmp/s3iamcerts/iam-server.key \ + --private-client-ca /tmp/s3iamcerts/ca.pem & +IAM_PID=$! + +sleep 2 + +if ! kill -0 $IAM_PID; then + echo "standalone IAM service no longer running" + exit 1 +fi + +GOCOVERDIR=/tmp/s3iamgw.covdata ./versitygw -p :7082 -a user -s pass \ + --iam-standalone-endpoint 127.0.0.1:7081 \ + --iam-standalone-client-cert /tmp/s3iamcerts/gw-client.pem \ + --iam-standalone-client-cert-key /tmp/s3iamcerts/gw-client.key \ + --iam-standalone-server-ca /tmp/s3iamcerts/ca.pem \ + posix $SIDECAR_FLAG /tmp/s3iamgw & +GW_S3IAM_PID=$! + +sleep 2 + +if ! kill -0 $GW_S3IAM_PID; then + echo "s3 gateway backed by standalone IAM no longer running" + kill $IAM_PID + exit 1 +fi + +if ! ./versitygw test -a user -s pass -e http://127.0.0.1:7082 --iam-endpoint http://127.0.0.1:7080 s3-iam; then + echo "s3 + standalone IAM access control tests failed" + kill $GW_S3IAM_PID + kill $IAM_PID + exit 1 +fi + +kill $GW_S3IAM_PID +kill $IAM_PID + exit 0 # ----------------------------------------------------------------------------- @@ -256,6 +310,8 @@ exit 0 # /tmp/versioning.covdata # /tmp/versioning.https.covdata # /tmp/noacl.covdata +# /tmp/s3iam.covdata (standalone IAM service) +# /tmp/s3iamgw.covdata (s3 gateway backed by it) # # This gives you coverage metrics isolated per test suite / server mode. # @@ -265,7 +321,7 @@ exit 0 # If you want a unified report combining all environments: # # go tool covdata merge \ -# -i=/tmp/covdata,/tmp/https.covdata,/tmp/versioning.covdata,/tmp/versioning.https.covdata,/tmp/noacl.covdata \ +# -i=/tmp/covdata,/tmp/https.covdata,/tmp/versioning.covdata,/tmp/versioning.https.covdata,/tmp/noacl.covdata,/tmp/s3iam.covdata,/tmp/s3iamgw.covdata \ # -o /tmp/allcovdata # # go tool covdata percent -i=/tmp/allcovdata diff --git a/s3api/admin-server.go b/s3api/admin-server.go index c9f40048..d06b98e5 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -25,9 +25,9 @@ import ( "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/s3api/controllers" "github.com/versity/versitygw/s3api/middlewares" - "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3log" ) @@ -35,7 +35,7 @@ type S3AdminServer struct { app *fiber.App backend backend.Backend router *S3AdminRouter - CertStorage *utils.CertStorage + CertStorage *netutil.CertStorage quiet bool debug bool corsAllowOrigin string @@ -100,7 +100,7 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region type AdminOpt func(s *S3AdminServer) -func WithAdminSrvTLS(cs *utils.CertStorage) AdminOpt { +func WithAdminSrvTLS(cs *netutil.CertStorage) AdminOpt { return func(s *S3AdminServer) { s.CertStorage = cs } } @@ -152,9 +152,9 @@ func (sa *S3AdminServer) ServeMultiPort(ports []string) error { var err error if sa.CertStorage != nil { - ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: sa.socketPerm}) + ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: sa.socketPerm}) } else { - ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, portSpec, utils.ListenerOptions{SocketPerm: sa.socketPerm}) + ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: sa.socketPerm}) } if err != nil { @@ -169,7 +169,7 @@ func (sa *S3AdminServer) ServeMultiPort(ports []string) error { } // Combine all listeners - finalListener := utils.NewMultiListener(listeners...) + finalListener := netutil.NewMultiListener(listeners...) return sa.app.Listener(finalListener, fiber.ListenConfig{ DisableStartupMessage: true, diff --git a/s3api/controllers/admin.go b/s3api/controllers/admin.go index 089a4719..f627497e 100644 --- a/s3api/controllers/admin.go +++ b/s3api/controllers/admin.go @@ -135,7 +135,7 @@ func (c AdminController) ChangeBucketOwner(ctx fiber.Ctx) (*Response, error) { owner := ctx.Query("owner") bucket := ctx.Query("bucket") - accs, err := auth.CheckIfAccountsExist([]string{owner}, c.iam) + accs, err := c.iam.ResolveAccounts([]string{owner}) if err != nil { return &Response{ MetaOpts: &MetaOptions{}, diff --git a/s3api/controllers/admin_test.go b/s3api/controllers/admin_test.go index 4fdfbb04..10c6cfa4 100644 --- a/s3api/controllers/admin_test.go +++ b/s3api/controllers/admin_test.go @@ -18,6 +18,7 @@ import ( "context" "encoding/xml" "errors" + "fmt" "net/http" "testing" @@ -487,8 +488,15 @@ func TestAdminController_ChangeBucketOwner(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { iam := &IAMServiceMock{ - GetUserAccountFunc: func(access string) (auth.Account, error) { - return auth.Account{}, tt.input.extraMockErr + ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) { + switch tt.input.extraMockErr { + case nil: + return []string{}, nil + case auth.ErrNoSuchUser: + return accessKeyIDs, nil + default: + return nil, fmt.Errorf("check user account: %w", tt.input.extraMockErr) + } }, } be := &BackendMock{ @@ -674,6 +682,9 @@ func TestAdminController_CreateBucket(t *testing.T) { GetUserAccountFunc: func(access string) (auth.Account, error) { return auth.Account{}, tt.input.extraMockErr }, + ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) { + return []string{}, nil + }, } be := &BackendMock{ CreateBucketFunc: func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error { diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index 03dda5b6..11be7824 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -21,6 +21,7 @@ import ( "sort" "strings" + "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" @@ -76,6 +77,34 @@ func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs } } +// verifyAccess wraps auth.VerifyAccess, always injecting the controller's +// own configured IAM backend, readonly mode, and disableACL setting into opts +func (c S3ApiController) verifyAccess(ctx fiber.Ctx, opts auth.AccessOptions) error { + opts.Iam = c.iam + opts.Readonly = c.readonly + opts.DisableACL = c.disableACL + return auth.VerifyAccess(ctx, c.be, opts) +} + +// verifyObjectsAccess wraps auth.VerifyObjectsAccess, for the one request +// shape (DeleteObjects) that names several objects at once. The returned +// slice has one entry per object (nil where it may proceed); the error +// return is a whole-request failure, not about any one object. +func (c S3ApiController) verifyObjectsAccess(ctx fiber.Ctx, opts auth.AccessOptions, objects []types.ObjectIdentifier, bypass auth.BypassMode) ([]error, error) { + opts.Iam = c.iam + opts.Readonly = c.readonly + opts.DisableACL = c.disableACL + return auth.VerifyObjectsAccess(ctx, c.be, opts, objects, bypass) +} + +// verifyObjectCopyAccess wraps auth.VerifyObjectCopyAccess +func (c S3ApiController) verifyObjectCopyAccess(ctx fiber.Ctx, copySource string, opts auth.AccessOptions) error { + opts.Iam = c.iam + opts.Readonly = c.readonly + opts.DisableACL = c.disableACL + return auth.VerifyObjectCopyAccess(ctx, c.be, copySource, opts) +} + func (c S3ApiController) getAclHeaderValue(ctx fiber.Ctx, key string, defaultValues ...string) string { if c.disableACL { return "" diff --git a/s3api/controllers/base_test.go b/s3api/controllers/base_test.go index fb403825..46e01d9b 100644 --- a/s3api/controllers/base_test.go +++ b/s3api/controllers/base_test.go @@ -76,6 +76,8 @@ type testInput struct { beErr error extraMockErr error extraMockResp any + readonly bool + disableACL bool } type testOutput struct { diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go index b847e919..c110f243 100644 --- a/s3api/controllers/bucket-delete.go +++ b/s3api/controllers/bucket-delete.go @@ -29,9 +29,8 @@ func (c S3ApiController) DeleteBucketTagging(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -39,7 +38,6 @@ func (c S3ApiController) DeleteBucketTagging(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketTaggingAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -64,16 +62,14 @@ func (c S3ApiController) DeleteBucketOwnershipControls(ctx fiber.Ctx) (*Response isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, Acc: acct, Bucket: bucket, Actions: []auth.Action{auth.PutBucketOwnershipControlsAction}, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -98,16 +94,14 @@ func (c S3ApiController) DeleteBucketPolicy(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, Acc: acct, Bucket: bucket, Actions: []auth.Action{auth.DeleteBucketPolicyAction}, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -133,9 +127,8 @@ func (c S3ApiController) DeleteBucketCors(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -143,7 +136,6 @@ func (c S3ApiController) DeleteBucketCors(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketCorsAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -169,9 +161,8 @@ func (c S3ApiController) DeleteBucketWebsite(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -179,7 +170,6 @@ func (c S3ApiController) DeleteBucketWebsite(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.DeleteBucketWebsiteAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -205,9 +195,8 @@ func (c S3ApiController) DeleteBucket(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -215,7 +204,6 @@ func (c S3ApiController) DeleteBucket(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.DeleteBucketAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index 14962e8c..9e287fd7 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -32,8 +32,7 @@ func (c S3ApiController) GetBucketTagging(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -41,7 +40,6 @@ func (c S3ApiController) GetBucketTagging(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketTaggingAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -85,8 +83,7 @@ func (c S3ApiController) GetBucketOwnershipControls(ctx fiber.Ctx) (*Response, e isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -94,7 +91,6 @@ func (c S3ApiController) GetBucketOwnershipControls(ctx fiber.Ctx) (*Response, e Bucket: bucket, Actions: []auth.Action{auth.GetBucketOwnershipControlsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -126,8 +122,7 @@ func (c S3ApiController) GetBucketVersioning(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -135,7 +130,6 @@ func (c S3ApiController) GetBucketVersioning(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketVersioningAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -169,8 +163,7 @@ func (c S3ApiController) GetBucketCors(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -178,7 +171,6 @@ func (c S3ApiController) GetBucketCors(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketCorsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -213,8 +205,7 @@ func (c S3ApiController) GetBucketWebsite(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -222,7 +213,6 @@ func (c S3ApiController) GetBucketWebsite(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketWebsiteAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -257,8 +247,7 @@ func (c S3ApiController) GetBucketPolicy(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -266,7 +255,6 @@ func (c S3ApiController) GetBucketPolicy(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketPolicyAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -292,8 +280,7 @@ func (c S3ApiController) GetBucketPolicyStatus(ctx fiber.Ctx) (*Response, error) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -301,7 +288,6 @@ func (c S3ApiController) GetBucketPolicyStatus(ctx fiber.Ctx) (*Response, error) Bucket: bucket, Actions: []auth.Action{auth.GetBucketPolicyStatusAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -354,8 +340,7 @@ func (c S3ApiController) ListObjectVersions(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -363,7 +348,6 @@ func (c S3ApiController) ListObjectVersions(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.ListBucketVersionsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -408,8 +392,7 @@ func (c S3ApiController) GetObjectLockConfiguration(ctx fiber.Ctx) (*Response, e isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -417,7 +400,6 @@ func (c S3ApiController) GetObjectLockConfiguration(ctx fiber.Ctx) (*Response, e Bucket: bucket, Actions: []auth.Action{auth.GetBucketObjectLockConfigurationAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -454,8 +436,7 @@ func (c S3ApiController) GetBucketAcl(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionReadAcp, IsRoot: isRoot, @@ -463,7 +444,6 @@ func (c S3ApiController) GetBucketAcl(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketAclAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -506,8 +486,7 @@ func (c S3ApiController) ListMultipartUploads(ctx fiber.Ctx) (*Response, error) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -515,7 +494,6 @@ func (c S3ApiController) ListMultipartUploads(ctx fiber.Ctx) (*Response, error) Bucket: bucket, Actions: []auth.Action{auth.ListBucketMultipartUploadsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -568,8 +546,7 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) { region = defaultRegion } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -577,7 +554,6 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.ListBucketAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -641,8 +617,7 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) { region = defaultRegion } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -650,7 +625,6 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.ListBucketAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -704,8 +678,7 @@ func (c S3ApiController) GetBucketLocation(ctx fiber.Ctx) (*Response, error) { isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -713,7 +686,6 @@ func (c S3ApiController) GetBucketLocation(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.GetBucketLocationAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/bucket-head.go b/s3api/controllers/bucket-head.go index f54214db..67c74c2f 100644 --- a/s3api/controllers/bucket-head.go +++ b/s3api/controllers/bucket-head.go @@ -32,9 +32,8 @@ func (c S3ApiController) HeadBucket(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -42,7 +41,6 @@ func (c S3ApiController) HeadBucket(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.ListBucketAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/bucket-post.go b/s3api/controllers/bucket-post.go index 40dfec08..564fbf81 100644 --- a/s3api/controllers/bucket-post.go +++ b/s3api/controllers/bucket-post.go @@ -34,34 +34,19 @@ import ( func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") - bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true") + bypass := auth.BypassModeForRequest(strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")) acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, - auth.AccessOptions{ - Readonly: c.readonly, - Acl: parsedAcl, - AclPermission: auth.PermissionWrite, - IsRoot: isRoot, - Acc: acct, - Bucket: bucket, - Actions: []auth.Action{auth.DeleteObjectAction}, - IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, - }) - if err != nil { - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, err - } - + // The body has to be parsed before authorization, not after: real AWS + // authorizes s3:DeleteObject against each object's own ARN, so the keys + // are part of what is being authorized. The parsed objects go straight + // to VerifyObjectsAccess, which checks policy and object locks for all + // of them in one pass. var dObj s3response.DeleteObjects - err = xml.Unmarshal(ctx.BodyRaw(), &dObj) + err := xml.Unmarshal(ctx.BodyRaw(), &dObj) if err != nil { debuglogger.Logf("error unmarshalling delete objects: %v", err) return &Response{ @@ -71,7 +56,23 @@ func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) { }, s3err.GetAPIError(s3err.ErrInvalidRequest) } - err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, dObj.Objects, bypass, IsBucketPublic, c.be, false) + // checkErrs holds one entry per requested object — nil where it may + // proceed to the backend, an AWS-shaped denial otherwise. DeleteObjects + // supports partial success, so a denial on one object (policy or object + // lock) must not fail any other: only the objects that clear this check + // are sent to the backend, and the rest are reported as per-object + // errors directly from checkErrs. err here is a whole-request failure + // (readonly mode, or an error resolving policy/lock state), not about + // any one object. + checkErrs, err := c.verifyObjectsAccess(ctx, + auth.AccessOptions{ + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + IsPublicRequest: IsBucketPublic, + }, dObj.Objects, bypass) if err != nil { return &Response{ MetaOpts: &MetaOptions{ @@ -80,15 +81,26 @@ func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) { }, err } - res, err := c.be.DeleteObjects(ctx.RequestCtx(), - &s3.DeleteObjectsInput{ - Bucket: &bucket, - Delete: &types.Delete{ - Objects: dObj.Objects, - }, - }) + toDelete := make([]types.ObjectIdentifier, 0, len(dObj.Objects)) + for i, obj := range dObj.Objects { + if checkErrs[i] == nil { + toDelete = append(toDelete, obj) + } + } + + var backendResult s3response.DeleteResult + if len(toDelete) > 0 { + backendResult, err = c.be.DeleteObjects(ctx.RequestCtx(), + &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{ + Objects: toDelete, + }, + }) + } + return &Response{ - Data: res, + Data: utils.MergeDeleteObjectsResult(dObj.Objects, checkErrs, backendResult), MetaOpts: &MetaOptions{ ObjectCount: int64(len(dObj.Objects)), BucketOwner: parsedAcl.Owner, @@ -115,9 +127,8 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) { key := parsed.Fields["key"] - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -125,7 +136,6 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutObjectAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/bucket-post_test.go b/s3api/controllers/bucket-post_test.go index 4d4aa968..20a50dad 100644 --- a/s3api/controllers/bucket-post_test.go +++ b/s3api/controllers/bucket-post_test.go @@ -46,19 +46,36 @@ func TestS3ApiController_DeleteObjects(t *testing.T) { validRes := s3response.DeleteResult{ Deleted: []types.DeletedObject{ - {Key: utils.GetStringPtr("key")}, + {Key: utils.GetStringPtr("obj")}, }, } + partialSuccessBody, err := xml.Marshal(s3response.DeleteObjects{ + Objects: []types.ObjectIdentifier{ + {Key: utils.GetStringPtr("locked")}, + {Key: utils.GetStringPtr("ok")}, + }, + }) + assert.NoError(t, err) + + lockConfig, err := json.Marshal(auth.BucketLockConfig{Enabled: true}) + assert.NoError(t, err) + + legalHoldOn, legalHoldOff := true, false + lockedObjectCode := "AccessDenied" + lockedObjectMessage := "Access Denied because object protected by object lock." + tests := []struct { - name string - input testInput - output testOutput + name string + input testInput + output testOutput + configureMock func(be *BackendMock) }{ { name: "verify access fails", input: testInput{ locals: accessDeniedLocals, + body: validBody, }, output: testOutput{ response: &Response{ @@ -140,6 +157,56 @@ func TestS3ApiController_DeleteObjects(t *testing.T) { }, }, }, + { + name: "partial success: one object locked, one succeeds", + input: testInput{ + locals: defaultLocals, + body: partialSuccessBody, + }, + output: testOutput{ + response: &Response{ + Data: s3response.DeleteResult{ + Deleted: []types.DeletedObject{ + {Key: utils.GetStringPtr("ok")}, + }, + Error: []types.Error{ + {Key: utils.GetStringPtr("locked"), Code: &lockedObjectCode, Message: &lockedObjectMessage}, + }, + }, + MetaOpts: &MetaOptions{ + BucketOwner: "root", + EventName: s3event.EventObjectRemovedDeleteObjects, + ObjectCount: 2, + }, + }, + }, + configureMock: func(be *BackendMock) { + be.GetObjectLockConfigurationFunc = func(contextMoqParam context.Context, bucket string) ([]byte, error) { + return lockConfig, nil + } + be.GetBucketVersioningFunc = func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) { + return s3response.GetBucketVersioningOutput{}, nil + } + be.GetObjectRetentionFunc = func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) { + return []byte("{}"), nil + } + be.GetObjectLegalHoldFunc = func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) { + if object == "locked" { + return &legalHoldOn, nil + } + return &legalHoldOff, nil + } + be.DeleteObjectsFunc = func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) { + // Only the object that cleared the lock check should + // ever reach the backend. + assert.Len(t, deleteObjectsInput.Delete.Objects, 1) + assert.Equal(t, "ok", *deleteObjectsInput.Delete.Objects[0].Key) + return s3response.DeleteResult{ + Deleted: []types.DeletedObject{{Key: utils.GetStringPtr("ok")}}, + }, nil + } + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -154,6 +221,9 @@ func TestS3ApiController_DeleteObjects(t *testing.T) { return nil, tt.input.extraMockErr }, } + if tt.configureMock != nil { + tt.configureMock(be) + } ctrl := S3ApiController{ be: be, diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go index 38fa16e5..def17455 100644 --- a/s3api/controllers/bucket-put.go +++ b/s3api/controllers/bucket-put.go @@ -37,8 +37,7 @@ func (c S3ApiController) PutBucketTagging(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -46,7 +45,6 @@ func (c S3ApiController) PutBucketTagging(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketTaggingAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -80,15 +78,13 @@ func (c S3ApiController) PutBucketOwnershipControls(ctx fiber.Ctx) (*Response, e acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) - if err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + if err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, Acc: acct, Bucket: bucket, Actions: []auth.Action{auth.PutBucketOwnershipControlsAction}, - DisableACL: c.disableACL, }); err != nil { return &Response{ MetaOpts: &MetaOptions{ @@ -140,8 +136,7 @@ func (c S3ApiController) PutBucketVersioning(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -149,7 +144,6 @@ func (c S3ApiController) PutBucketVersioning(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketVersioningAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -195,8 +189,7 @@ func (c S3ApiController) PutObjectLockConfiguration(ctx fiber.Ctx) (*Response, e isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - if err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + if err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -204,7 +197,6 @@ func (c S3ApiController) PutObjectLockConfiguration(ctx fiber.Ctx) (*Response, e Bucket: bucket, Actions: []auth.Action{auth.PutBucketObjectLockConfigurationAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }); err != nil { return &Response{ MetaOpts: &MetaOptions{ @@ -237,8 +229,7 @@ func (c S3ApiController) PutBucketCors(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -246,7 +237,6 @@ func (c S3ApiController) PutBucketCors(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketCorsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -294,8 +284,7 @@ func (c S3ApiController) PutBucketWebsite(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -303,7 +292,6 @@ func (c S3ApiController) PutBucketWebsite(ctx fiber.Ctx) (*Response, error) { Bucket: bucket, Actions: []auth.Action{auth.PutBucketWebsiteAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -357,15 +345,13 @@ func (c S3ApiController) PutBucketPolicy(ctx fiber.Ctx) (*Response, error) { acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, Acc: acct, Bucket: bucket, Actions: []auth.Action{auth.PutBucketPolicyAction}, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -409,16 +395,14 @@ func (c S3ApiController) PutBucketAcl(ctx fiber.Ctx) (*Response, error) { grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP var input *auth.PutBucketAclInput - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWriteAcp, IsRoot: isRoot, Acc: acct, Bucket: bucket, Actions: []auth.Action{auth.PutBucketAclAction}, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -585,11 +569,12 @@ func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) { utils.ContextKeyBucketOwner.Set(ctx, creator) } bucketOwner := utils.ContextKeyBucketOwner.Get(ctx).(auth.Account) + isRoot, _ := utils.ContextKeyIsRoot.Get(ctx).(bool) - if creator.Role != auth.RoleAdmin && creator.Role != auth.RoleUserPlus { + if err := auth.VerifyCreateBucketAccess(ctx, c.iam, isRoot, creator, bucket); err != nil { return &Response{ MetaOpts: &MetaOptions{}, - }, s3err.GetAPIError(s3err.ErrAccessDenied) + }, err } // validate the bucket name diff --git a/s3api/controllers/bucket-put_test.go b/s3api/controllers/bucket-put_test.go index c699eb8a..b4045881 100644 --- a/s3api/controllers/bucket-put_test.go +++ b/s3api/controllers/bucket-put_test.go @@ -716,6 +716,10 @@ func TestS3ApiController_CreateBucket(t *testing.T) { Access: "user", Role: auth.RoleUser, } + userPlusAcc := auth.Account{ + Access: "userplus", + Role: auth.RoleUserPlus, + } invLocConstBody, err := xml.Marshal(s3response.CreateBucketConfiguration{ LocationConstraint: utils.GetStringPtr("us-west-1"), @@ -732,6 +736,7 @@ func TestS3ApiController_CreateBucket(t *testing.T) { input: testInput{ locals: map[utils.ContextKey]any{ utils.ContextKeyAccount: userAcc, + utils.ContextKeyIsRoot: false, }, }, output: testOutput{ @@ -916,6 +921,47 @@ func TestS3ApiController_CreateBucket(t *testing.T) { }, }, }, + { + name: "userplus role can create bucket", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: userPlusAcc, + }, + bucket: "my-bucket", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: userPlusAcc.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/my-bucket"), + "x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"), + }, + }, + }, + }, + { + name: "root bypasses role check", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyAccount: userAcc, + utils.ContextKeyIsRoot: true, + }, + bucket: "my-bucket", + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: userAcc.Access, + }, + Headers: map[string]*string{ + "Location": utils.GetStringPtr("/my-bucket"), + "x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"), + }, + }, + }, + }, } for _, tt := range tests { diff --git a/s3api/controllers/iam_moq_test.go b/s3api/controllers/iam_moq_test.go index b131ec2e..c2150dfe 100644 --- a/s3api/controllers/iam_moq_test.go +++ b/s3api/controllers/iam_moq_test.go @@ -30,6 +30,9 @@ var _ auth.IAMService = &IAMServiceMock{} // ListUserAccountsFunc: func() ([]auth.Account, error) { // panic("mock out the ListUserAccounts method") // }, +// ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) { +// panic("mock out the ResolveAccounts method") +// }, // ShutdownFunc: func() error { // panic("mock out the Shutdown method") // }, @@ -55,6 +58,9 @@ type IAMServiceMock struct { // ListUserAccountsFunc mocks the ListUserAccounts method. ListUserAccountsFunc func() ([]auth.Account, error) + // ResolveAccountsFunc mocks the ResolveAccounts method. + ResolveAccountsFunc func(accessKeyIDs []string) ([]string, error) + // ShutdownFunc mocks the Shutdown method. ShutdownFunc func() error @@ -81,6 +87,11 @@ type IAMServiceMock struct { // ListUserAccounts holds details about calls to the ListUserAccounts method. ListUserAccounts []struct { } + // ResolveAccounts holds details about calls to the ResolveAccounts method. + ResolveAccounts []struct { + // AccessKeyIDs is the accessKeyIDs argument value. + AccessKeyIDs []string + } // Shutdown holds details about calls to the Shutdown method. Shutdown []struct { } @@ -96,6 +107,7 @@ type IAMServiceMock struct { lockDeleteUserAccount sync.RWMutex lockGetUserAccount sync.RWMutex lockListUserAccounts sync.RWMutex + lockResolveAccounts sync.RWMutex lockShutdown sync.RWMutex lockUpdateUserAccount sync.RWMutex } @@ -223,6 +235,38 @@ func (mock *IAMServiceMock) ListUserAccountsCalls() []struct { return calls } +// ResolveAccounts calls ResolveAccountsFunc. +func (mock *IAMServiceMock) ResolveAccounts(accessKeyIDs []string) ([]string, error) { + if mock.ResolveAccountsFunc == nil { + panic("IAMServiceMock.ResolveAccountsFunc: method is nil but IAMService.ResolveAccounts was just called") + } + callInfo := struct { + AccessKeyIDs []string + }{ + AccessKeyIDs: accessKeyIDs, + } + mock.lockResolveAccounts.Lock() + mock.calls.ResolveAccounts = append(mock.calls.ResolveAccounts, callInfo) + mock.lockResolveAccounts.Unlock() + return mock.ResolveAccountsFunc(accessKeyIDs) +} + +// ResolveAccountsCalls gets all the calls that were made to ResolveAccounts. +// Check the length with: +// +// len(mockedIAMService.ResolveAccountsCalls()) +func (mock *IAMServiceMock) ResolveAccountsCalls() []struct { + AccessKeyIDs []string +} { + var calls []struct { + AccessKeyIDs []string + } + mock.lockResolveAccounts.RLock() + calls = mock.calls.ResolveAccounts + mock.lockResolveAccounts.RUnlock() + return calls +} + // Shutdown calls ShutdownFunc. func (mock *IAMServiceMock) Shutdown() error { if mock.ShutdownFunc == nil { diff --git a/s3api/controllers/object-delete.go b/s3api/controllers/object-delete.go index 111b3957..58bf0f54 100644 --- a/s3api/controllers/object-delete.go +++ b/s3api/controllers/object-delete.go @@ -41,9 +41,8 @@ func (c S3ApiController) DeleteObjectTagging(ctx fiber.Ctx) (*Response, error) { action = auth.DeleteObjectVersionTaggingAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -52,7 +51,6 @@ func (c S3ApiController) DeleteObjectTagging(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -85,9 +83,8 @@ func (c S3ApiController) AbortMultipartUpload(ctx fiber.Ctx) (*Response, error) isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -96,7 +93,6 @@ func (c S3ApiController) AbortMultipartUpload(ctx fiber.Ctx) (*Response, error) Object: key, Actions: []auth.Action{auth.AbortMultipartUploadAction}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -125,7 +121,7 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) versionId := ctx.Query("versionId") - bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true") + bypass := auth.BypassModeForRequest(strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")) ifMatch := utils.GetStringPtr(strings.Trim(ctx.Get("If-Match"), `"`)) ifMatchLastModTime := utils.ParsePreconditionDateHeader(ctx.Get("X-Amz-If-Match-Last-Modified-Time")) ifMatchSize := utils.ParseIfMatchSize(ctx) @@ -140,9 +136,8 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) { action = auth.DeleteObjectVersionAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -151,7 +146,6 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -162,9 +156,9 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) { } err = auth.CheckObjectAccess( - ctx.RequestCtx(), + ctx, bucket, - acct.Access, + acct, []types.ObjectIdentifier{ { Key: &key, @@ -174,6 +168,7 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) { bypass, isBucketPublic, c.be, + c.iam, false, ) if err != nil { diff --git a/s3api/controllers/object-get.go b/s3api/controllers/object-get.go index 51f29bd8..c109d499 100644 --- a/s3api/controllers/object-get.go +++ b/s3api/controllers/object-get.go @@ -45,8 +45,7 @@ func (c S3ApiController) GetObjectTagging(ctx fiber.Ctx) (*Response, error) { action = auth.GetObjectVersionTaggingAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -55,7 +54,6 @@ func (c S3ApiController) GetObjectTagging(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -103,8 +101,7 @@ func (c S3ApiController) GetObjectRetention(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -113,7 +110,6 @@ func (c S3ApiController) GetObjectRetention(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.GetObjectRetentionAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -151,8 +147,7 @@ func (c S3ApiController) GetObjectLegalHold(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -161,7 +156,6 @@ func (c S3ApiController) GetObjectLegalHold(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.GetObjectLegalHoldAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -189,8 +183,7 @@ func (c S3ApiController) GetObjectAcl(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionReadAcp, IsRoot: isRoot, @@ -199,7 +192,6 @@ func (c S3ApiController) GetObjectAcl(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.GetObjectAclAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -232,8 +224,7 @@ func (c S3ApiController) ListParts(ctx fiber.Ctx) (*Response, error) { parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -242,7 +233,6 @@ func (c S3ApiController) ListParts(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.ListMultipartUploadPartsAction}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -304,8 +294,7 @@ func (c S3ApiController) GetObjectAttributes(ctx fiber.Ctx) (*Response, error) { action = auth.GetObjectVersionAttributesAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -314,7 +303,6 @@ func (c S3ApiController) GetObjectAttributes(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -429,8 +417,7 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) { action = auth.GetObjectVersionAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -439,7 +426,6 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isPublicBucketRequest, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/object-head.go b/s3api/controllers/object-head.go index 5974e638..8776ae64 100644 --- a/s3api/controllers/object-head.go +++ b/s3api/controllers/object-head.go @@ -76,9 +76,8 @@ func (c S3ApiController) HeadObject(ctx fiber.Ctx) (*Response, error) { action = auth.GetObjectVersionAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -87,7 +86,6 @@ func (c S3ApiController) HeadObject(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: isPublicBucket, - DisableACL: c.disableACL, }) if err != nil { return &Response{ diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index fb2052db..bc785972 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -39,9 +39,8 @@ func (c S3ApiController) RestoreObject(ctx fiber.Ctx) (*Response, error) { isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -50,7 +49,6 @@ func (c S3ApiController) RestoreObject(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.RestoreObjectAction}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -91,9 +89,8 @@ func (c S3ApiController) SelectObjectContent(ctx fiber.Ctx) (*Response, error) { isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionRead, IsRoot: isRoot, @@ -102,7 +99,6 @@ func (c S3ApiController) SelectObjectContent(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.GetObjectAction}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -175,9 +171,8 @@ func (c S3ApiController) CreateMultipartUpload(ctx fiber.Ctx) (*Response, error) actions = append(actions, auth.PutObjectRetentionAction) } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -185,7 +180,6 @@ func (c S3ApiController) CreateMultipartUpload(ctx fiber.Ctx) (*Response, error) Bucket: bucket, Object: key, Actions: actions, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -278,9 +272,8 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -289,7 +282,6 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro Object: key, Actions: []auth.Action{auth.PutObjectAction}, IsPublicRequest: isBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -363,7 +355,7 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro ifMatch, ifNoneMatch := utils.ParsePreconditionMatchHeaders(ctx) - err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, isBucketPublic, c.be, true) + err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, isBucketPublic, c.be, c.iam, true) if err != nil { return &Response{ MetaOpts: &MetaOptions{ diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index 12331cd3..f88260ce 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -47,8 +47,7 @@ func (c S3ApiController) PutObjectTagging(ctx fiber.Ctx) (*Response, error) { action = auth.PutObjectVersionTaggingAction } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -57,7 +56,6 @@ func (c S3ApiController) PutObjectTagging(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{action}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -98,8 +96,7 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) { IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -108,7 +105,6 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.PutObjectRetentionAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -129,7 +125,7 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) { } // check if the operation is allowed - err = auth.IsObjectLockRetentionPutAllowed(ctx.RequestCtx(), c.be, bucket, key, versionId, acct.Access, retention, bypass) + err = auth.IsObjectLockRetentionPutAllowed(ctx, c.be, c.iam, bucket, key, versionId, acct, retention, bypass) if err != nil { return &Response{ MetaOpts: &MetaOptions{ @@ -165,8 +161,7 @@ func (c S3ApiController) PutObjectLegalHold(ctx fiber.Ctx) (*Response, error) { IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{ - Readonly: c.readonly, + err := c.verifyAccess(ctx, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -175,7 +170,6 @@ func (c S3ApiController) PutObjectLegalHold(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.PutObjectLegalHoldAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -234,9 +228,8 @@ func (c S3ApiController) UploadPart(ctx fiber.Ctx) (*Response, error) { contentLengthStr = decodedLength } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -245,7 +238,6 @@ func (c S3ApiController) UploadPart(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.PutObjectAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -361,7 +353,7 @@ func (c S3ApiController) UploadPartCopy(ctx fiber.Ctx) (*Response, error) { }, err } - err = auth.VerifyObjectCopyAccess(ctx.RequestCtx(), c.be, copySource, + err = c.verifyObjectCopyAccess(ctx, copySource, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, @@ -371,7 +363,6 @@ func (c S3ApiController) UploadPartCopy(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: []auth.Action{auth.PutObjectAction}, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -444,9 +435,8 @@ func (c S3ApiController) PutObjectAcl(ctx fiber.Ctx) (*Response, error) { isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -525,7 +515,7 @@ func (c S3ApiController) CopyObject(ctx fiber.Ctx) (*Response, error) { actions = append(actions, auth.PutObjectRetentionAction) } - err = auth.VerifyObjectCopyAccess(ctx.RequestCtx(), c.be, copySource, + err = c.verifyObjectCopyAccess(ctx, copySource, auth.AccessOptions{ Acl: parsedAcl, AclPermission: auth.PermissionWrite, @@ -609,7 +599,7 @@ func (c S3ApiController) CopyObject(ctx fiber.Ctx) (*Response, error) { preconditionHdrs := utils.ParsePreconditionHeaders(ctx, utils.WithCopySource()) - err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, false, c.be, true) + err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, false, c.be, c.iam, true) if err != nil { return &Response{ MetaOpts: &MetaOptions{ @@ -710,9 +700,8 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) { actions = append(actions, auth.PutObjectRetentionAction) } - err := auth.VerifyAccess(ctx.RequestCtx(), c.be, + err := c.verifyAccess(ctx, auth.AccessOptions{ - Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, IsRoot: isRoot, @@ -721,7 +710,6 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) { Object: key, Actions: actions, IsPublicRequest: IsBucketPublic, - DisableACL: c.disableACL, }) if err != nil { return &Response{ @@ -750,7 +738,7 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) { }, err } - err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true) + err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, IsBucketPublic, c.be, c.iam, true) if err != nil { return &Response{ MetaOpts: &MetaOptions{ diff --git a/s3api/controllers/object-put_test.go b/s3api/controllers/object-put_test.go index deb3641f..4aa7347a 100644 --- a/s3api/controllers/object-put_test.go +++ b/s3api/controllers/object-put_test.go @@ -603,6 +603,27 @@ func TestS3ApiController_UploadPartCopy(t *testing.T) { err: s3err.GetAPIError(s3err.ErrAccessDenied), }, }, + { + name: "readonly mode blocks upload part copy", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/key", + }, + queries: map[string]string{ + "partNumber": "2", + }, + readonly: true, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, { name: "invalid copy source", input: testInput{ @@ -729,7 +750,9 @@ func TestS3ApiController_UploadPartCopy(t *testing.T) { } ctrl := S3ApiController{ - be: be, + be: be, + readonly: tt.input.readonly, + disableACL: tt.input.disableACL, } testController( @@ -805,7 +828,7 @@ func TestS3ApiController_PutObjectAcl(t *testing.T) { return tt.input.beErr }, GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { - return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy) }, } @@ -849,6 +872,58 @@ func TestS3ApiController_CopyObject(t *testing.T) { err: s3err.GetAPIError(s3err.ErrAccessDenied), }, }, + { + name: "readonly mode blocks copy object", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/object", + }, + readonly: true, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, + { + name: "disableACL blocks a non-owner's grantee-based access", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyIsRoot: false, + utils.ContextKeyParsedAcl: auth.ACL{ + Owner: "root", + Grantees: []auth.Grantee{ + { + Access: "user", + Permission: auth.PermissionWrite, + Type: types.TypeCanonicalUser, + }, + }, + }, + utils.ContextKeyAccount: auth.Account{ + Access: "user", + Role: auth.RoleUser, + }, + }, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/object", + }, + disableACL: true, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAccessDenied), + }, + }, { name: "invalid copy source", input: testInput{ @@ -1059,7 +1134,7 @@ func TestS3ApiController_CopyObject(t *testing.T) { return tt.input.beRes.(s3response.CopyObjectOutput), tt.input.beErr }, GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { - return nil, s3err.GetAPIError(s3err.ErrAccessDenied) + return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy) }, GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) { return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented) @@ -1070,7 +1145,9 @@ func TestS3ApiController_CopyObject(t *testing.T) { } ctrl := S3ApiController{ - be: be, + be: be, + readonly: tt.input.readonly, + disableACL: tt.input.disableACL, } testController( diff --git a/s3api/middlewares/authentication.go b/s3api/middlewares/authentication.go index 2f1cea90..7d8ab356 100644 --- a/s3api/middlewares/authentication.go +++ b/s3api/middlewares/authentication.go @@ -17,12 +17,14 @@ package middlewares import ( "crypto/sha256" "encoding/hex" + "errors" "io" "strconv" "time" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/internal/sigv4auth" "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3err" ) @@ -39,7 +41,7 @@ type RootUserConfig struct { } func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, streamBody, requireContentSha256, allowDefaultRegion bool) fiber.Handler { - acct := accounts{root: root, iam: iam} + rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin} return func(ctx fiber.Ctx) error { // The bucket is public, no need to check this signature @@ -89,10 +91,15 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, utils.ContextKeyIsRoot.Set(ctx, authData.Access == root.Access) - account, err := acct.getAccount(authData.Access) + sessionToken := ctx.Get(sigv4auth.HeaderSecurityToken) + + derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, authData.Access, sessionToken, authData.Date, authData.Region, sigv4auth.ServiceS3) if err == auth.ErrNoSuchUser { return s3err.GetInvalidAccessKeyIdErr(authData.Access) } + if errors.Is(err, auth.ErrInvalidSessionToken) { + return s3err.GetAPIError(s3err.ErrInvalidToken) + } if err != nil { return err } @@ -126,7 +133,7 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, return s3err.GetAPIError(s3err.ErrInvalidSHA256PayloadUsage) } - canonicalString, err := utils.CheckValidSignature(ctx, authData, account.Secret, hashPayload, tdate, contentLength) + canonicalString, err := utils.CheckValidSignature(ctx, authData, derivedKey, hashPayload, tdate, contentLength) if err != nil { return err } @@ -153,7 +160,7 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, if utils.IsStreamingPayload(hashPayload) { wrapBodyReader(ctx, func(r io.Reader) io.Reader { var cr io.Reader - cr, err = utils.NewChunkReader(ctx, r, authData, canonicalString, account.Secret, tdate) + cr, err = utils.NewChunkReader(ctx, r, authData, canonicalString, derivedKey, tdate) return cr }) if err != nil { @@ -190,20 +197,3 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, return nil } } - -type accounts struct { - root RootUserConfig - iam auth.IAMService -} - -func (a accounts) getAccount(access string) (auth.Account, error) { - if access == a.root.Access { - return auth.Account{ - Access: a.root.Access, - Secret: a.root.Secret, - Role: auth.RoleAdmin, - }, nil - } - - return a.iam.GetUserAccount(access) -} diff --git a/s3api/middlewares/host-style-parser.go b/s3api/middlewares/host-style-parser.go index e0d5afc2..80966cc7 100644 --- a/s3api/middlewares/host-style-parser.go +++ b/s3api/middlewares/host-style-parser.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/internal/httpctx" ) // HostStyleParser is a middleware which parses the bucket name @@ -31,6 +32,11 @@ func HostStyleParser(virtualDomain string) fiber.Handler { if !found || bucket == "" { return ctx.Next() } + // SigV4 verification signs the request's original, on-the-wire path, + // not the bucket-prefixed one used for routing here — save it + // before ctx.Path() below overwrites fasthttp's URI.PathOriginal too. + httpctx.ContextKeyOriginalURIPath.Set(ctx, string(ctx.Request().URI().PathOriginal())) + path := ctx.Path() if path == "/" { // omit the trailing / for bucket operations diff --git a/s3api/middlewares/object-post-auth.go b/s3api/middlewares/object-post-auth.go index 6e55f3a9..34494b00 100644 --- a/s3api/middlewares/object-post-auth.go +++ b/s3api/middlewares/object-post-auth.go @@ -16,6 +16,7 @@ package middlewares import ( "bytes" + "errors" "mime" "strconv" "time" @@ -23,16 +24,18 @@ import ( "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/auth" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/sigv4auth" "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3err" ) const ( - formFieldPolicy = "policy" - formFieldAlgorithm = "x-amz-algorithm" - formFieldCredential = "x-amz-credential" - formFieldDate = "x-amz-date" - formFieldSignature = "x-amz-signature" + formFieldPolicy = "policy" + formFieldAlgorithm = "x-amz-algorithm" + formFieldCredential = "x-amz-credential" + formFieldDate = "x-amz-date" + formFieldSignature = "x-amz-signature" + formFieldSecurityToken = "x-amz-security-token" aws4HMACSHA256 = "AWS4-HMAC-SHA256" hourSeconds = 60 * 60 @@ -47,7 +50,7 @@ type PostObjectResult struct { } func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string) fiber.Handler { - acct := accounts{root: root, iam: iam} + rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin} return func(ctx fiber.Ctx) error { contentLengthStr := ctx.Get("Content-Length") @@ -164,11 +167,15 @@ func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string return s3err.PostAuth.IncorrectRegion(credentialStr, region, creds.Region) } - account, err := acct.getAccount(creds.Access) + derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, creds.Access, fields[formFieldSecurityToken], creds.Date, creds.Region, sigv4auth.ServiceS3) if err == auth.ErrNoSuchUser { debuglogger.Logf("POST object access key not found: %s", creds.Access) return s3err.GetInvalidAccessKeyIdErr(creds.Access) } + if errors.Is(err, auth.ErrInvalidSessionToken) { + debuglogger.Logf("invalid POST object security token for access key %s", creds.Access) + return s3err.GetAPIError(s3err.ErrInvalidToken) + } if err != nil { debuglogger.Logf("failed to resolve POST object account %q: %v", creds.Access, err) return err @@ -177,7 +184,7 @@ func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string utils.ContextKeyAccount.Set(ctx, account) utils.ContextKeyIsRoot.Set(ctx, account.Access == root.Access) - expectedSig, err := utils.SignPostPolicy(policyB64, creds.Date, region, account.Secret) + expectedSig, err := utils.SignPostPolicy(policyB64, derivedKey) if err != nil { return err } diff --git a/s3api/middlewares/object-post-auth_test.go b/s3api/middlewares/object-post-auth_test.go index db278747..6e9e7877 100644 --- a/s3api/middlewares/object-post-auth_test.go +++ b/s3api/middlewares/object-post-auth_test.go @@ -28,6 +28,7 @@ import ( "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" + "github.com/versity/versitygw/internal/sigv4auth" "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3err" ) @@ -186,7 +187,8 @@ func TestAuthorizePostObject_SignedRequest(t *testing.T) { map[string]string{"bucket": "mybucket"}, []any{"starts-with", "$key", "uploads/"}, }) - sig, err := utils.SignPostPolicy(policyB64, dateShort, region, secretKey) + derivedKey := sigv4auth.DeriveKey(secretKey, dateShort, region, sigv4auth.ServiceS3) + sig, err := utils.SignPostPolicy(policyB64, derivedKey) assert.NoError(t, err) var gotAuthenticated bool diff --git a/s3api/middlewares/presign-auth.go b/s3api/middlewares/presign-auth.go index ceae9bd3..29a416fa 100644 --- a/s3api/middlewares/presign-auth.go +++ b/s3api/middlewares/presign-auth.go @@ -15,17 +15,19 @@ package middlewares import ( + "errors" "io" "strconv" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/internal/sigv4auth" "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3err" ) func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region string, streamBody bool) fiber.Handler { - acct := accounts{root: root, iam: iam} + rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin} return func(ctx fiber.Ctx) error { // The bucket is public, no need to check this signature @@ -40,11 +42,6 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region return s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism) } - if ctx.Request().URI().QueryArgs().Has("X-Amz-Security-Token") { - // OIDC Authorization with X-Amz-Security-Token is not supported - return s3err.QueryAuthErrors.SecurityTokenNotSupported() - } - // Set in the context the "authenticated" key, in case the authentication succeeds, // otherwise the middleware will return the caucht error utils.ContextKeyAuthenticated.Set(ctx, true) @@ -56,10 +53,15 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region utils.ContextKeyIsRoot.Set(ctx, authData.Access == root.Access) - account, err := acct.getAccount(authData.Access) + sessionToken := ctx.Query(sigv4auth.QuerySecurityToken) + + derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, authData.Access, sessionToken, authData.Date[:8], authData.Region, sigv4auth.ServiceS3) if err == auth.ErrNoSuchUser { return s3err.GetInvalidAccessKeyIdErr(authData.Access) } + if errors.Is(err, auth.ErrInvalidSessionToken) { + return s3err.GetAPIError(s3err.ErrInvalidToken) + } if err != nil { return err } @@ -75,7 +77,7 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region } } - err = utils.CheckPresignedSignature(ctx, authData, account.Secret) + err = utils.CheckPresignedSignature(ctx, authData, derivedKey) if err != nil { return err } diff --git a/s3api/middlewares/public-bucket.go b/s3api/middlewares/public-bucket.go index 28be6bed..ac39bc37 100644 --- a/s3api/middlewares/public-bucket.go +++ b/s3api/middlewares/public-bucket.go @@ -57,7 +57,7 @@ func AuthorizePublicBucketAccess(be backend.Backend, s3action string, policyPerm } bucket, object := parsePath(ctx.Path()) - err := auth.VerifyPublicAccess(ctx.RequestCtx(), be, policyPermission, permission, bucket, object) + err := auth.VerifyPublicAccess(ctx, be, policyPermission, permission, bucket, object) if err != nil { if s3action == metrics.ActionHeadBucket { // add the bucket region header for HeadBucket diff --git a/s3api/server.go b/s3api/server.go index c0d542ee..e8563f3b 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -29,6 +29,7 @@ import ( "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/metrics" "github.com/versity/versitygw/s3api/controllers" "github.com/versity/versitygw/s3api/middlewares" @@ -48,7 +49,7 @@ type S3ApiServer struct { Router *S3ApiRouter app *fiber.App backend backend.Backend - CertStorage *utils.CertStorage + CertStorage *netutil.CertStorage quiet bool keepAlive bool health string @@ -237,7 +238,7 @@ func validateMiddlewareMount(mount middlewareMount) error { type Option func(*S3ApiServer) // WithTLS sets TLS Credentials -func WithTLS(cs *utils.CertStorage) Option { +func WithTLS(cs *netutil.CertStorage) Option { return func(s *S3ApiServer) { s.CertStorage = cs } } @@ -365,9 +366,9 @@ func (sa *S3ApiServer) ServeMultiPort(ports []string) error { var err error if sa.CertStorage != nil { - ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: sa.socketPerm}) + ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: sa.socketPerm}) } else { - ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, portSpec, utils.ListenerOptions{SocketPerm: sa.socketPerm}) + ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: sa.socketPerm}) } if err != nil { return fmt.Errorf("failed to bind s3 listener %s: %w", portSpec, err) @@ -381,7 +382,7 @@ func (sa *S3ApiServer) ServeMultiPort(ports []string) error { } // Combine all listeners - finalListener := utils.NewMultiListener(listeners...) + finalListener := netutil.NewMultiListener(listeners...) if sa.onListen != nil { fn := sa.onListen diff --git a/s3api/server_test.go b/s3api/server_test.go index c76999f0..0a845bb6 100644 --- a/s3api/server_test.go +++ b/s3api/server_test.go @@ -25,8 +25,8 @@ import ( "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/s3api/middlewares" - "github.com/versity/versitygw/s3api/utils" ) func newTestS3ApiServer(opts ...Option) (*S3ApiServer, error) { @@ -69,7 +69,7 @@ func TestS3ApiServer_Serve(t *testing.T) { app: fiber.New(), backend: backend.BackendUnsupported{}, Router: &S3ApiRouter{}, - CertStorage: &utils.CertStorage{}, + CertStorage: &netutil.CertStorage{}, }, port: "localhost:notaport", }, diff --git a/s3api/utils/auth-reader.go b/s3api/utils/auth-reader.go index 12884d2b..8161b5e0 100644 --- a/s3api/utils/auth-reader.go +++ b/s3api/utils/auth-reader.go @@ -39,11 +39,13 @@ const ( 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) { - result, err := sigv4auth.CheckSignature(ctx, auth, secret, checksum, tdate, contentLen, sigv4auth.CheckOptions{ - Service: service, - DisableURIPathEscaping: true, +// CheckValidSignature validates the ctx v4 auth signature against +// derivedKey — the request's kSigning value, either derived locally from a +// known secret or obtained from a standalone IAM service that never reveals +// the secret itself. +func CheckValidSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte, checksum string, tdate time.Time, contentLen int64) (string, error) { + result, err := sigv4auth.CheckSignature(ctx, auth, derivedKey, checksum, tdate, contentLen, sigv4auth.CheckOptions{ + Service: service, }) if err != nil { return "", mapSigV4Error(err) @@ -86,24 +88,11 @@ func ParseCredentials(input string, errHandler CredsError) (*CredentialsScope, e return creds, nil } -func SignPostPolicy(base64Policy, yyyymmdd, region, secretKey string) (string, error) { - signingKey := deriveSigningKey(secretKey, yyyymmdd, region) - sig := hmacSHA256(signingKey, []byte(base64Policy)) - return hex.EncodeToString(sig), nil -} - -func deriveSigningKey(secretKey, yyyymmdd, region string) []byte { - kDate := hmacSHA256([]byte("AWS4"+secretKey), []byte(yyyymmdd)) - kRegion := hmacSHA256(kDate, []byte(region)) - kService := hmacSHA256(kRegion, []byte(service)) - kSigning := hmacSHA256(kService, []byte("aws4_request")) - return kSigning -} - -func hmacSHA256(key, data []byte) []byte { - h := hmac.New(sha256.New, key) - h.Write(data) - return h.Sum(nil) +// SignPostPolicy signs a POST-policy document with derivedKey +func SignPostPolicy(base64Policy string, derivedKey []byte) (string, error) { + h := hmac.New(sha256.New, derivedKey) + h.Write([]byte(base64Policy)) + return hex.EncodeToString(h.Sum(nil)), nil } func mapSigV4Error(err error) error { diff --git a/s3api/utils/auth_test.go b/s3api/utils/auth_test.go index 7997b257..af1f97a2 100644 --- a/s3api/utils/auth_test.go +++ b/s3api/utils/auth_test.go @@ -16,14 +16,14 @@ package utils import ( "net" + "strings" "testing" "time" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/gofiber/fiber/v3" "github.com/valyala/fasthttp" "github.com/valyala/fasthttp/fasthttputil" - v4 "github.com/versity/versitygw/aws/signer/v4" + "github.com/versity/versitygw/internal/sigv4auth" ) func TestAuthParse(t *testing.T) { @@ -93,36 +93,19 @@ func Test_Client_UserAgent(t *testing.T) { } app.Get("/", func(c fiber.Ctx) error { - req, err := createHttpRequestFromCtx(c, signedHdrs, int64(c.Request().Header.ContentLength())) - if err != nil { - t.Fatal(err) + auth := sigv4auth.AuthData{ + Access: access, + Region: region, + Service: service, + SignedHeaders: strings.Join(signedHdrs, ";"), + Signature: expectedSig, } + derivedKey := sigv4auth.DeriveKey(secret, dateStr[:8], region, service) + opts := sigv4auth.CheckOptions{DisableURIPathEscaping: true} + contentLen := int64(c.Request().Header.ContentLength()) - req.Host = host - req.Header.Set("X-Amz-Content-Sha256", zeroLenSig) - - signer := v4.NewSigner() - - _, signErr := signer.SignHTTP(req.Context(), - aws.Credentials{ - AccessKeyID: access, - SecretAccessKey: secret, - }, - req, zeroLenSig, service, region, tdate, signedHdrs, - func(options *v4.SignerOptions) { - options.DisableURIPathEscaping = true - }) - if signErr != nil { - t.Fatalf("sign generated http request: %v", err) - } - - genAuth, err := ParseAuthorization(req.Header.Get("Authorization")) - if err != nil { - return err - } - - if genAuth.Signature != expectedSig { - t.Errorf("SIG: %v\nexpected: %v\n", genAuth.Signature, expectedSig) + if _, err := sigv4auth.CheckSignature(c, auth, derivedKey, zeroLenSig, tdate, contentLen, opts); err != nil { + t.Errorf("CheckSignature: %v", err) } return c.Send(c.Request().Header.UserAgent()) @@ -145,9 +128,19 @@ func Test_Client_UserAgent(t *testing.T) { defer fasthttp.ReleaseRequest(req) defer fasthttp.ReleaseResponse(resp) - req.SetRequestURI("http://example.com") + // Host/User-Agent/X-Amz-Content-Sha256/X-Amz-Date are sent as real + // headers, reproducing the captured request verbatim, so CheckSignature + // extracts them straight off the live fiber.Ctx like it does for any + // real request. + req.SetRequestURI("http://" + host + "/") req.Header.SetUserAgent(agent) + req.Header.Set("X-Amz-Content-Sha256", zeroLenSig) + req.Header.Set("X-Amz-Date", dateStr) if err := client.Do(req, resp); err != nil { t.Fatal(err) } + + if got := string(resp.Body()); got != agent { + t.Errorf("user-agent got %q, expected %q", got, agent) + } } diff --git a/s3api/utils/chunk-reader.go b/s3api/utils/chunk-reader.go index 62f5d6e6..f966202b 100644 --- a/s3api/utils/chunk-reader.go +++ b/s3api/utils/chunk-reader.go @@ -192,7 +192,7 @@ func ParseDecodedContentLength(ctx fiber.Ctx) (int64, error) { return decContLength, nil } -func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalString, secret string, date time.Time) (io.Reader, error) { +func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalString string, derivedKey []byte, date time.Time) (io.Reader, error) { cLength, err := ParseDecodedContentLength(ctx) if err != nil { return nil, err @@ -214,9 +214,9 @@ func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalStri case payloadTypeStreamingUnsignedTrailer: return NewUnsignedChunkReader(r, checksumType, cLength) case payloadTypeStreamingSignedTrailer: - return NewSignedChunkReader(r, authdata, canonicalString, secret, date, checksumType, true, cLength) + return NewSignedChunkReader(r, authdata, canonicalString, derivedKey, date, checksumType, true, cLength) case payloadTypeStreamingSigned: - return NewSignedChunkReader(r, authdata, canonicalString, secret, date, "", false, cLength) + return NewSignedChunkReader(r, authdata, canonicalString, derivedKey, date, "", false, cLength) // return not supported for: // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD // - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER diff --git a/s3api/utils/multi_listener.go b/s3api/utils/multi_listener.go deleted file mode 100644 index d03f9402..00000000 --- a/s3api/utils/multi_listener.go +++ /dev/null @@ -1,399 +0,0 @@ -// 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 utils - -import ( - "crypto/tls" - "errors" - "fmt" - "net" - "os" - "path/filepath" - "strings" - "sync" -) - -// MultiListener implements net.Listener and accepts connections from multiple -// underlying listeners. This is useful for listening on multiple IP addresses -// that a hostname resolves to (e.g., both IPv4 and IPv6 for "localhost"). -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 -} - -// NewMultiListener creates a new MultiListener that accepts connections from -// all provided listeners. -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{}), - } - - // Start accepting from each listener in its own goroutine - for _, ln := range listeners { - ml.wg.Add(1) - go ml.acceptLoop(ln) - } - - return ml -} - -// acceptLoop continuously accepts connections from a single listener -// and forwards them to the accept channel -func (ml *MultiListener) acceptLoop(ln net.Listener) { - defer ml.wg.Done() - - for { - conn, err := ln.Accept() - - select { - case <-ml.closeCh: - // MultiListener is closing - if conn != nil { - conn.Close() - } - return - case ml.acceptCh <- acceptResult{conn: conn, err: err}: - // Connection or error sent successfully - if err != nil { - return - } - } - } -} - -// Accept waits for and returns the next connection from any of the listeners -func (ml *MultiListener) Accept() (net.Conn, error) { - select { - case <-ml.closeCh: - return nil, errors.New("listener closed") - case result, ok := <-ml.acceptCh: - if !ok { - // Channel closed - return nil, errors.New("listener closed") - } - return result.conn, result.err - } -} - -// Close closes all underlying listeners -func (ml *MultiListener) Close() error { - var errs []error - - ml.closeOnce.Do(func() { - close(ml.closeCh) - - // Close all listeners - for _, ln := range ml.listeners { - if err := ln.Close(); err != nil { - errs = append(errs, err) - } - } - - // Wait for all accept loops to finish - ml.wg.Wait() - - // Drain any remaining accepts - close(ml.acceptCh) - for range ml.acceptCh { - } - }) - - if len(errs) > 0 { - return fmt.Errorf("errors closing listeners: %v", errs) - } - return nil -} - -// Addr returns the address of the first listener -func (ml *MultiListener) Addr() net.Addr { - if len(ml.listeners) > 0 { - return ml.listeners[0].Addr() - } - return nil -} - -// IsUnixSocketPath reports whether addr should be treated as a UNIX domain -// socket path rather than a TCP/IP address. It does so by attempting to parse -// addr as a host:port spec using net.SplitHostPort; anything that cannot be -// parsed that way (e.g. "/path/to/socket", "./rel/socket", "@abstract") is -// considered a socket path. -func IsUnixSocketPath(addr string) bool { - _, _, err := net.SplitHostPort(addr) - return err != nil -} - -// AbsSocketPaths converts any relative UNIX socket paths in addrs to absolute -// paths using the current working directory. Non-socket addresses (TCP/IP) and -// abstract sockets ("@name") are returned unchanged. This should be called -// early in program startup — before any backend that calls os.Chdir — so that -// relative paths are resolved against the shell's working directory. -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 -} - -// isAbstractSocket reports whether addr is a Linux abstract namespace socket. -// Abstract sockets start with "@"; Go's net package maps this to a leading -// null byte (\0) in the sockaddr, so no socket file is created on disk. -func isAbstractSocket(addr string) bool { - return strings.HasPrefix(addr, "@") -} - -// removeStaleSocket removes a leftover UNIX socket file at path so the -// address can be reused. It returns an error if the path exists but is not -// a socket, protecting regular files and directories from accidental deletion. -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) -} - -// ResolveHostnameIPs resolves a hostname to all its IP addresses (IPv4 and IPv6). -// If the input is already an IP address or empty, it returns it as-is. -// This is useful for determining all addresses a server will listen on. -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) - } - - // Handle empty host (e.g., ":8080" means all interfaces) - if host == "" { - return []string{""}, nil - } - - // If already an IP address, return as is - if net.ParseIP(host) != nil { - return []string{host}, nil - } - - // Resolve hostname to all IP addresses - 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) - } - - // Convert IPs to strings - result := make([]string, 0, len(ips)) - for _, ip := range ips { - result = append(result, ip.String()) - } - - return result, nil -} - -// resolveHostnameAddrs resolves a hostname to all its IP addresses (IPv4 and IPv6) -// and returns them as a list of addresses with the port attached. -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 is empty or already an IP address, return as is - if host == "" || net.ParseIP(host) != nil { - return []string{address}, nil - } - - // Resolve hostname to all IP addresses - 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) - } - - // Build list of addresses with port - addrs := make([]string, 0, len(ips)) - for _, ip := range ips { - addr := net.JoinHostPort(ip.String(), port) - addrs = append(addrs, addr) - } - - return addrs, nil -} - -// ListenerOptions configures optional behaviour for NewMultiAddrListener and -// NewMultiAddrTLSListener. -type ListenerOptions struct { - // SocketPerm, when non-zero, sets the file-mode permissions on file-backed - // UNIX sockets after binding. It is ignored for TCP/IP addresses and - // abstract namespace sockets. - SocketPerm os.FileMode -} - -// NewMultiAddrListener creates listeners for all IP addresses that the hostname -// in the address resolves to. If the address is already an IP, it creates a -// single listener. Returns a MultiListener if multiple addresses are resolved, -// or a single listener if only one address is found. -// -// UNIX domain socket forms are also supported: -// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale -// socket file is removed before binding. -// - "@name" — Linux abstract namespace socket; no file is created or removed. -// -// opts.SocketPerm, when non-zero, sets the file-mode permissions on file-backed -// sockets after binding. It is ignored for TCP/IP addresses and abstract sockets. -func NewMultiAddrListener(network, address string, opts ListenerOptions) (net.Listener, error) { - if IsUnixSocketPath(address) { - // For file-backed sockets, remove any stale socket file so re-binding works cleanly. - // Abstract sockets (@name) have no filesystem entry; skip removal for them. - 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 - } - - // Create listeners for all resolved addresses - listeners := make([]net.Listener, 0, len(addrs)) - - for _, addr := range addrs { - ln, err := net.Listen(network, addr) - if err != nil { - // Close any listeners we've already created - for _, l := range listeners { - l.Close() - } - return nil, fmt.Errorf("failed to bind listener %s: %w", addr, err) - } - listeners = append(listeners, ln) - } - - // Return MultiListener for multiple addresses - return NewMultiListener(listeners...), nil -} - -// NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the -// hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS. -// -// UNIX domain socket forms are also supported: -// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale -// socket file is removed before binding. -// - "@name" — Linux abstract namespace socket; no file is created or removed. -// -// opts.SocketPerm, when non-zero, sets the file-mode permissions on file-backed -// sockets after binding. It is ignored for TCP/IP addresses and abstract sockets. -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 - } - - // Create TLS listeners for all resolved addresses - listeners := make([]net.Listener, 0, len(addrs)) - - for _, addr := range addrs { - ln, err := net.Listen(network, addr) - if err != nil { - // Close any listeners we've already created - 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 MultiListener for multiple addresses - return NewMultiListener(listeners...), nil -} diff --git a/s3api/utils/presign-auth-reader.go b/s3api/utils/presign-auth-reader.go index c22463f3..d376524b 100644 --- a/s3api/utils/presign-auth-reader.go +++ b/s3api/utils/presign-auth-reader.go @@ -28,8 +28,9 @@ const ( unsignedPayload string = "UNSIGNED-PAYLOAD" ) -// CheckPresignedSignature validates presigned request signature -func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error { +// CheckPresignedSignature validates a presigned request's signature against +// derivedKey +func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte) error { var contentLength int64 var err error contentLengthStr := ctx.Get("Content-Length") @@ -42,9 +43,8 @@ func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error date, _ := time.Parse(iso8601Format, auth.Date) - _, err = sigv4auth.CheckQuerySignature(ctx, auth, secret, unsignedPayload, date, contentLength, sigv4auth.CheckOptions{ - Service: service, - DisableURIPathEscaping: true, + _, err = sigv4auth.CheckQuerySignature(ctx, auth, derivedKey, unsignedPayload, date, contentLength, sigv4auth.CheckOptions{ + Service: service, }) if err != nil { return mapSigV4Error(err) @@ -133,7 +133,7 @@ func mapQueryAuthError(err error) error { queryErr.ServerTime.Format(time.RFC3339), ) case sigv4auth.ErrQuerySecurityToken: - return s3err.QueryAuthErrors.SecurityTokenNotSupported() + return s3err.GetAPIError(s3err.ErrInvalidToken) } } diff --git a/s3api/utils/signed-chunk-reader.go b/s3api/utils/signed-chunk-reader.go index d9e591f0..26e63097 100644 --- a/s3api/utils/signed-chunk-reader.go +++ b/s3api/utils/signed-chunk-reader.go @@ -40,7 +40,6 @@ import ( const ( zeroLenSig = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - awsV4 = "AWS4" awsS3Service = "s3" awsV4Request = "aws4_request" trailerSignatureHeader = "x-amz-trailer-signature:" @@ -84,11 +83,13 @@ type ChunkReader struct { // NewChunkReader reads from request body io.Reader and parses out the // chunk metadata in stream. The headers are validated for proper signatures. // Reading from the chunk reader will read only the object data stream -// without the chunk headers/trailers. -func NewSignedChunkReader(r io.Reader, authdata AuthData, canonicalString, secret string, date time.Time, chType checksumType, requireTrailer bool, cLength int64) (io.Reader, error) { +// without the chunk headers/trailers. derivedKey is the same SigV4 kSigning +// value the seed request's Authorization header was already checked +// against, reused here rather than re-derived or re-fetched. +func NewSignedChunkReader(r io.Reader, authdata AuthData, canonicalString string, derivedKey []byte, date time.Time, chType checksumType, requireTrailer bool, cLength int64) (io.Reader, error) { chRdr := &ChunkReader{ r: r, - signingKey: getSigningKey(secret, authdata.Region, date), + signingKey: derivedKey, // the authdata.Signature is validated in the auth-reader, // so we can use that here without any other checks prevSig: authdata.Signature, @@ -353,18 +354,6 @@ func (cr *ChunkReader) parseAndRemoveChunkInfo(p []byte) (int, error) { return n, nil } -// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html -// Task 3: Calculate Signature -// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html#signing-request-intro -func getSigningKey(secret, region string, date time.Time) []byte { - dateKey := hmac256([]byte(awsV4+secret), []byte(date.Format(yyyymmdd))) - dateRegionKey := hmac256(dateKey, []byte(region)) - dateRegionServiceKey := hmac256(dateRegionKey, []byte(awsS3Service)) - signingKey := hmac256(dateRegionServiceKey, []byte(awsV4Request)) - debuglogger.Infof("signing key: %s", hex.EncodeToString(signingKey)) - return signingKey -} - func hmac256(key []byte, data []byte) []byte { hash := hmac.New(sha256.New, key) hash.Write(data) diff --git a/s3api/utils/signed_headers_test.go b/s3api/utils/signed_headers_test.go index 12a7db98..01938d5f 100644 --- a/s3api/utils/signed_headers_test.go +++ b/s3api/utils/signed_headers_test.go @@ -14,23 +14,24 @@ package utils import ( - "context" "net/http" "net/url" "testing" "time" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/require" "github.com/valyala/fasthttp" - v4 "github.com/versity/versitygw/aws/signer/v4" + "github.com/versity/versitygw/internal/sigv4auth" "github.com/versity/versitygw/s3err" ) const signedHeadersTestRegion = "us-east-1" -var signedHeadersTestCreds = aws.Credentials{ +var signedHeadersTestCreds = struct { + AccessKeyID string + SecretAccessKey string +}{ AccessKeyID: "AKID", SecretAccessKey: "SECRET", } @@ -43,7 +44,7 @@ func TestCheckPresignedSignatureRejectsUnsignedAmzHeader(t *testing.T) { authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion) require.NoError(t, err) - err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey) + err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData)) requireHeadersNotSigned(t, err, "x-amz-copy-source") } @@ -56,7 +57,7 @@ func TestCheckPresignedSignatureAllowsSignedAmzHeader(t *testing.T) { authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion) require.NoError(t, err) - err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey) + err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData)) require.NoError(t, err) } @@ -69,7 +70,7 @@ func TestCheckPresignedSignatureAllowsUnsignedNonAmzHeader(t *testing.T) { authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion) require.NoError(t, err) - err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey) + err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData)) require.NoError(t, err) } @@ -78,7 +79,7 @@ func TestCheckValidSignatureRejectsUnsignedAmzHeader(t *testing.T) { "X-Amz-Tagging": []string{"a=b"}, }) - _, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0) + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) requireHeadersNotSigned(t, err, "x-amz-tagging") } @@ -87,7 +88,7 @@ func TestCheckValidSignatureAllowsSignedAmzHeader(t *testing.T) { "X-Amz-Tagging": []string{"a=b"}, }, nil) - _, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0) + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) require.NoError(t, err) } @@ -97,7 +98,7 @@ func TestCheckValidSignatureAllowsUnsignedNonAmzHeader(t *testing.T) { "X-Custom-Header": []string{"value"}, }) - _, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0) + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) require.NoError(t, err) } @@ -109,7 +110,7 @@ func TestCheckPresignedSignatureRejectsUnsignedAmzHeaderPattern(t *testing.T) { authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion) require.NoError(t, err) - err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey) + err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData)) requireHeadersNotSigned(t, err, "x-amz-some-other-header") } @@ -118,10 +119,14 @@ func TestCheckValidSignatureRejectsUnsignedAmzHeaderPattern(t *testing.T) { "X-Amz-Some-Other-Header": []string{"value"}, }) - _, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0) + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) requireHeadersNotSigned(t, err, "x-amz-some-other-header") } +func derivedKeyFor(authData AuthData) []byte { + return sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, authData.Date[:8], signedHeadersTestRegion, service) +} + func buildPresignedURL(t *testing.T, headers http.Header) string { t.Helper() @@ -132,23 +137,22 @@ func buildPresignedURL(t *testing.T, headers http.Header) string { req.Header = make(http.Header) } - signer := v4.NewSigner() - signedURL, _, _, err := signer.PresignHTTP( - context.Background(), - signedHeadersTestCreds, - req, - unsignedPayload, - service, - signedHeadersTestRegion, - time.Now().UTC(), - nil, - func(options *v4.SignerOptions) { - options.DisableURIPathEscaping = true - }, - ) - require.NoError(t, err) + signingTime := time.Now().UTC() + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, yyyymmdd, signedHeadersTestRegion, service) - return signedURL + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = signedHeadersTestCreds.AccessKeyID + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, signedHeadersTestRegion, service) + in.PayloadHash = unsignedPayload + in.SigningTime = signingTime + in.DisableURIPathEscaping = true + in.IsPreSign = true + result := sigv4auth.BuildAndSign(derivedKey, in) + + signedURL := *req.URL + signedURL.RawQuery = result.RawQuery + return signedURL.String() } func signedHeaderAuthCtx(t *testing.T, signedHeaders, extraHeaders http.Header) (fiber.Ctx, AuthData, time.Time) { @@ -162,21 +166,18 @@ func signedHeaderAuthCtx(t *testing.T, signedHeaders, extraHeaders http.Header) req.Header = make(http.Header) } - signer := v4.NewSigner() - _, err = signer.SignHTTP( - context.Background(), - signedHeadersTestCreds, - req, - unsignedPayload, - service, - signedHeadersTestRegion, - signingTime, - nil, - func(options *v4.SignerOptions) { - options.DisableURIPathEscaping = true - }, - ) - require.NoError(t, err) + yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, yyyymmdd, signedHeadersTestRegion, service) + + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = signedHeadersTestCreds.AccessKeyID + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, signedHeadersTestRegion, service) + in.PayloadHash = unsignedPayload + in.SigningTime = signingTime + in.DisableURIPathEscaping = true + result := sigv4auth.BuildAndSign(derivedKey, in) + req.Header.Set("X-Amz-Date", result.AmzDate) + req.Header.Set("Authorization", result.AuthorizationHeader) headers := req.Header.Clone() for key, values := range extraHeaders { @@ -225,3 +226,46 @@ func requireHeadersNotSigned(t *testing.T, err error, expected string) { require.Equal(t, "AccessDenied", serr.Code) require.Equal(t, expected, serr.HeadersNotSigned) } + +// TestCheckValidSignatureRejectsUnsignedSecurityToken pins the entire +// binding argument for a session credential presented via header auth. +// +// The gateway adds no RequiredSignedHeaders entry for +// X-Amz-Security-Token, and deliberately so: passing a non-nil list +// *replaces* sigv4auth's default rule (host plus every X-Amz-* header) +// rather than adding to it, which would weaken the binding for every other +// X-Amz-* header. What keeps the token bound to the signature is that +// default rule alone — so if anyone ever hands CheckValidSignature an +// explicit list, this test is what catches it. +func TestCheckValidSignatureRejectsUnsignedSecurityToken(t *testing.T) { + ctx, authData, signingTime := signedHeaderAuthCtx(t, nil, http.Header{ + "X-Amz-Security-Token": []string{"a-session-token"}, + }) + + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) + requireHeadersNotSigned(t, err, "x-amz-security-token") +} + +// TestCheckValidSignatureAllowsSignedSecurityToken is the positive half: +// a token that *was* part of the signed request passes, so a legitimate +// session credential is not rejected by the rule above. +func TestCheckValidSignatureAllowsSignedSecurityToken(t *testing.T) { + ctx, authData, signingTime := signedHeaderAuthCtx(t, http.Header{ + "X-Amz-Security-Token": []string{"a-session-token"}, + }, nil) + + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) + require.NoError(t, err) +} + +// TestCheckValidSignatureRejectsSwappedSecurityToken confirms the token +// cannot be swapped for another session's after signing: it is part of the +// canonical request, so altering it invalidates the signature. +func TestCheckValidSignatureRejectsSwappedSecurityToken(t *testing.T) { + signed := http.Header{"X-Amz-Security-Token": []string{"the-real-session-token"}} + ctx, authData, signingTime := signedHeaderAuthCtx(t, signed, nil) + ctx.Request().Header.Set("X-Amz-Security-Token", "somebody-elses-session-token") + + _, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0) + require.Error(t, err, "swapping the security token after signing must invalidate the signature") +} diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go index d1271954..f34edcf4 100644 --- a/s3api/utils/utils.go +++ b/s3api/utils/utils.go @@ -18,14 +18,11 @@ import ( "crypto/tls" "encoding/base64" "encoding/xml" - "errors" "fmt" "io" "net" - "net/http" "net/url" "regexp" - "slices" "strconv" "strings" "sync/atomic" @@ -34,7 +31,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/gofiber/fiber/v3" "github.com/valyala/fasthttp" - signerV4 "github.com/versity/versitygw/aws/signer/v4" + "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/s3err" "github.com/versity/versitygw/s3response" @@ -135,41 +132,6 @@ func ExtractMetadataFromFields(fields map[string]string) (map[string]string, err return metadata, nil } -func createHttpRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64) (*http.Request, error) { - req := ctx.Request() - - uri := ctx.OriginalURL() - - 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 - } - - // make sure all headers in the signed headers are present - for _, header := range signedHdrs { - if httpReq.Header.Get(header) == "" { - httpReq.Header.Set(header, "") - } - } - - // 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 { @@ -296,34 +258,6 @@ func IsValidBucketName(bucket string) bool { return true } -func includeHeader(hdr string, signedHdrs []string) bool { - return slices.ContainsFunc(signedHdrs, func(shdr string) bool { - return strings.EqualFold(hdr, shdr) - }) -} - -func addRequestHeadersFromCtx(ctx fiber.Ctx, httpReq *http.Request, signedHdrs []string) error { - headersNotSigned := []string{} - for key, value := range ctx.Request().Header.All() { - keyStr := string(key) - if includeHeader(keyStr, signedHdrs) || signerV4.IsIgnoredHeader(keyStr) { - httpReq.Header.Add(keyStr, string(value)) - continue - } - if signerV4.IsRequiredSignedHeader(keyStr) { - lowerKey := strings.ToLower(keyStr) - headersNotSigned = append(headersNotSigned, lowerKey) - } - } - - if len(headersNotSigned) != 0 { - debuglogger.Logf("headers present in request but not included in SignedHeaders: %q", strings.Join(headersNotSigned, ", ")) - return s3err.GetHeadersNotSignedErr(headersNotSigned) - } - - return nil -} - // expiration time window // https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationTimeStamp const timeExpirationSec = 15 * 60 // seconds @@ -1059,29 +993,6 @@ func GenerateObjectLocation(ctx fiber.Ctx, virtualDomain, bucket, object string) ) } -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 -} - func NewTLSListener(network string, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) { config := &tls.Config{ MinVersion: tls.VersionTLS12, @@ -1095,6 +1006,55 @@ func NewTLSListener(network string, address string, getCertificateFunc func(*tls return tls.NewListener(ln, config), nil } +// MergeDeleteObjectsResult builds the final DeleteObjects response, +// preserving the order objects were requested in across both the Deleted +// and Error lists. objects is the full request; checkErrs is +// VerifyObjectsAccess's per-object result for it (nil entries were sent to +// the backend); backendResult is the backend's response for just those. +// +// The backend's own Deleted/Error order is not assumed to match the order +// its objects were sent in, so objects are matched back to their backend +// result by identity (key + version) rather than by position, with a FIFO +// queue per identity to keep duplicate keys in the same request each paired +// with their own result. versionID is "" for a keyed (unversioned) delete, +// matching how both the request and every backend's response represent "no +// version specified" — as a nil pointer. +func MergeDeleteObjectsResult(objects []types.ObjectIdentifier, checkErrs []error, backendResult s3response.DeleteResult) s3response.DeleteResult { + type objectKey struct{ key, versionID string } + + deletedByKey := make(map[objectKey][]types.DeletedObject, len(backendResult.Deleted)) + for _, d := range backendResult.Deleted { + k := objectKey{backend.GetStringFromPtr(d.Key), backend.GetStringFromPtr(d.VersionId)} + deletedByKey[k] = append(deletedByKey[k], d) + } + errorByKey := make(map[objectKey][]types.Error, len(backendResult.Error)) + for _, e := range backendResult.Error { + k := objectKey{backend.GetStringFromPtr(e.Key), backend.GetStringFromPtr(e.VersionId)} + errorByKey[k] = append(errorByKey[k], e) + } + + var result s3response.DeleteResult + for i, obj := range objects { + if checkErrs[i] != nil { + result.Error = append(result.Error, s3err.ObjectDeleteError(obj.Key, obj.VersionId, checkErrs[i])) + continue + } + + k := objectKey{backend.GetStringFromPtr(obj.Key), backend.GetStringFromPtr(obj.VersionId)} + if queue := deletedByKey[k]; len(queue) > 0 { + result.Deleted = append(result.Deleted, queue[0]) + deletedByKey[k] = queue[1:] + continue + } + if queue := errorByKey[k]; len(queue) > 0 { + result.Error = append(result.Error, queue[0]) + errorByKey[k] = queue[1:] + } + } + + return result +} + func DetectResourceType(ctx fiber.Ctx) s3err.ResourceType { path := ctx.Path() if path == "" || path == "/" { diff --git a/s3api/utils/utils_test.go b/s3api/utils/utils_test.go index d496997a..10390de7 100644 --- a/s3api/utils/utils_test.go +++ b/s3api/utils/utils_test.go @@ -20,7 +20,6 @@ import ( "encoding/xml" "errors" "math/rand" - "net/http" "net/url" "reflect" "strings" @@ -28,7 +27,6 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" "github.com/valyala/fasthttp" "github.com/versity/versitygw/backend" @@ -36,67 +34,6 @@ import ( "github.com/versity/versitygw/s3response" ) -func TestCreateHttpRequestFromCtx(t *testing.T) { - type args struct { - ctx fiber.Ctx - } - - app := fiber.New() - - // Expected output, Case 1 - ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) - req := ctx.Request() - request, _ := http.NewRequest(string(req.Header.Method()), req.URI().String(), bytes.NewReader(req.Body())) - - // Case 2 - ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{}) - req2 := ctx2.Request() - req2.Header.Add("X-Amz-Mfa", "Some valid Mfa") - - request2, _ := http.NewRequest(string(req2.Header.Method()), req2.URI().String(), bytes.NewReader(req2.Body())) - request2.Header.Add("X-Amz-Mfa", "Some valid Mfa") - - tests := []struct { - name string - args args - want *http.Request - wantErr bool - hdrs []string - }{ - { - name: "Success-response", - args: args{ - ctx: ctx, - }, - want: request, - wantErr: false, - hdrs: []string{}, - }, - { - name: "Success-response-With-Headers", - args: args{ - ctx: ctx2, - }, - want: request2, - wantErr: false, - hdrs: []string{"X-Amz-Mfa"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := createHttpRequestFromCtx(tt.args.ctx, tt.hdrs, 0) - if (err != nil) != tt.wantErr { - t.Errorf("CreateHttpRequestFromCtx() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !reflect.DeepEqual(got.Header, tt.want.Header) { - t.Errorf("CreateHttpRequestFromCtx() got = %v, want %v", got, tt.want) - } - }) - } -} - // a helper method to construct a raw http request with the given http request headers // to further parse with fasthttp.Request.Read and return fasthttp.RequestHeader func createHeadersFromRawRequest(t *testing.T, hdrs [][2]string) *fasthttp.RequestHeader { @@ -229,42 +166,6 @@ func TestGetUserMetaData(t *testing.T) { } } -func Test_includeHeader(t *testing.T) { - type args struct { - hdr string - signedHdrs []string - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "include-header-falsy-case", - args: args{ - hdr: "Content-Type", - signedHdrs: []string{"X-Amz-Acl", "Content-Encoding"}, - }, - want: false, - }, - { - name: "include-header-falsy-case", - args: args{ - hdr: "Content-Type", - signedHdrs: []string{"X-Amz-Acl", "Content-Type"}, - }, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := includeHeader(tt.args.hdr, tt.args.signedHdrs); got != tt.want { - t.Errorf("includeHeader() = %v, want %v", got, tt.want) - } - }) - } -} - func TestIsValidBucketName(t *testing.T) { type args struct { bucket string diff --git a/s3err/presigned-urls.go b/s3err/presigned-urls.go index 20bcfa3d..2204e7a6 100644 --- a/s3err/presigned-urls.go +++ b/s3err/presigned-urls.go @@ -83,8 +83,4 @@ func (queryAuthErrors) OnlyHMACSupported() S3Error { return authQueryParamError("X-Amz-Algorithm only supports \"AWS4-HMAC-SHA256\"") } -func (queryAuthErrors) SecurityTokenNotSupported() S3Error { - return authQueryParamError("Authorization with X-Amz-Security-Token is not supported") -} - var QueryAuthErrors queryAuthErrors diff --git a/s3err/s3err.go b/s3err/s3err.go index 1b1348ff..5fe14131 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -126,6 +126,7 @@ const ( ErrMissingContentLength ErrContentLengthMismatch ErrInvalidAccessKeyID + ErrInvalidToken ErrRequestNotReadyYet ErrMissingDateHeader ErrGetUploadsWithKey @@ -397,6 +398,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The AWS Access Key Id you provided does not exist in our records.", HTTPStatusCode: http.StatusForbidden, }, + ErrInvalidToken: { + Code: "InvalidToken", + Description: "The provided token is malformed or otherwise invalid.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrRequestNotReadyYet: { Code: "AccessDenied", Description: "Request is not valid yet.", @@ -1002,6 +1008,28 @@ func GetWebsiteRoutingRulesLimitedErr(rules int) APIError { } } +func GetExplicitDenyAccessErr(principal, action, resourceArn, source string) APIError { + return APIError{ + Code: "AccessDenied", + Description: fmt.Sprintf( + "User: %s is not authorized to perform: %s on resource: %q with an explicit deny in %s", + principal, action, resourceArn, source, + ), + HTTPStatusCode: http.StatusForbidden, + } +} + +func GetImplicitDenyAccessErr(principal, action, resourceArn string) APIError { + return APIError{ + Code: "AccessDenied", + Description: fmt.Sprintf( + "User: %s is not authorized to perform: %s on resource: %q because no identity-based policy allows the %s action", + principal, action, resourceArn, action, + ), + HTTPStatusCode: http.StatusForbidden, + } +} + type ResourceType string const ( @@ -1011,3 +1039,23 @@ const ( ResourceTypeBucketPolicy ResourceType = "BUCKETPOLICY" ResourceTypeUpload ResourceType = "UPLOAD" ) + +func ObjectDeleteError(key, versionId *string, err error) types.Error { + if serr, ok := err.(S3Error); ok { + base := serr.BaseError() + return types.Error{ + Key: key, + VersionId: versionId, + Code: &base.Code, + Message: &base.Description, + } + } + message := err.Error() + code := "InternalError" + return types.Error{ + Key: key, + VersionId: versionId, + Code: &code, + Message: &message, + } +} diff --git a/tests/integration/Access_Control.go b/tests/integration/Access_Control.go index ad7d44df..ec4474a3 100644 --- a/tests/integration/Access_Control.go +++ b/tests/integration/Access_Control.go @@ -17,8 +17,11 @@ package integration import ( "bytes" "context" + "encoding/base64" + "encoding/json" "fmt" "io" + "strings" "time" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -287,7 +290,9 @@ func AccessControl_multi_statement_policy(s *S3Conf) error { Bucket: &bucket, }) cancel() - if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + if err := checkApiErr(err, s3err.GetExplicitDenyAccessErr( + testuser.access, "s3:DeleteBucket", fmt.Sprintf("arn:aws:s3:::%s", bucket), "a resource-based policy", + )); err != nil { return err } @@ -1110,3 +1115,565 @@ func AccessControl_CopyObject_with_retention_policy(s *S3Conf) error { return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: dstObj}}) }, withLock()) } + +// AccessControl_bucket_policy_condition_ip_allow covers a bucket-policy +// Allow statement scoped by an IpAddress Condition matching the caller's +// real source IP: 0.0.0.0/0 matches any IPv4 address, so this exercises +// the Condition machinery without depending on the test runner's actual +// address. +func AccessControl_bucket_policy_condition_ip_allow(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_ip_allow" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + Condition: json.RawMessage(`{"IpAddress":{"aws:SourceIp":"0.0.0.0/0"}}`), + }); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + _, err := putObjects(userClient, []string{"my-obj"}, bucket) + return err + }) +} + +// AccessControl_bucket_policy_condition_ip_deny_no_match covers the same +// shape as AccessControl_bucket_policy_condition_ip_allow with a CIDR +// (TEST-NET-3, RFC 5737) that can never match a real caller, so the Allow +// statement never applies and the request falls through to an implicit +// deny. +func AccessControl_bucket_policy_condition_ip_deny_no_match(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_ip_deny_no_match" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + Condition: json.RawMessage(`{"IpAddress":{"aws:SourceIp":"203.0.113.0/24"}}`), + }); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + _, err := putObjects(userClient, []string{"my-obj"}, bucket) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} + +// AccessControl_bucket_policy_condition_explicit_deny_overrides_allow +// covers a Deny statement scoped by a matching IpAddress Condition +// overriding a broader, unconditional Allow — the same explicit-deny-wins +// precedence bucket policies already have for unconditional statements, +// now confirmed to hold once one side's match depends on Condition +// evaluation too. +func AccessControl_bucket_policy_condition_explicit_deny_overrides_allow(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_explicit_deny_overrides_allow" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, + bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + }, + bucketStatement{ + Effect: "Deny", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + Condition: json.RawMessage(`{"IpAddress":{"aws:SourceIp":"0.0.0.0/0"}}`), + }, + ); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + _, err := putObjects(userClient, []string{"my-obj"}, bucket) + return checkApiErr(err, s3err.GetExplicitDenyAccessErr(testuser.access, "s3:PutObject", + fmt.Sprintf("arn:aws:s3:::%s/my-obj", bucket), "a resource-based policy")) + }) +} + +// AccessControl_bucket_policy_condition_s3_prefix covers the s3:prefix +// condition key, populated from a ListObjectsV2 request's own Prefix +// parameter: an Allow scoped to a specific prefix grants a request naming +// that exact prefix and denies one that doesn't. +func AccessControl_bucket_policy_condition_s3_prefix(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_s3_prefix" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(`{"StringEquals":{"s3:prefix":"photos/"}}`), + }); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr("photos/"), + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr("videos/"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} + +// The tests below cover every Condition operator family bucket policies +// support with at least one Allow and one Deny case each, each scoped to a +// real condition-context key the S3 gateway actually populates from the +// request, so the whole round trip - PutBucketPolicy, the live request, +// and the resulting Allow/Deny - is exercised end to end, not just the +// shared evaluator in isolation (already covered exhaustively by +// internal/condition's own unit tests). +// +// ArnEquals/ArnLike/ArnNotEquals/ArnNotLike are deliberately not covered +// here: they'd need aws:PrincipalArn, which bucket-policy Condition doesn't +// populate today (only identity-policy Condition does - see +// project_s3_bucket_policy_condition memory for why). The operator logic +// itself is still covered by internal/condition's unit tests +// (TestEvaluateConditionArn); what's untested is only the wiring, because +// there's nothing to wire yet. + +// AccessControl_bucket_policy_condition_string_operators covers the full +// String family (Equals/NotEquals/Like/NotLike, both plain and IgnoreCase) +// against s3:prefix, populated from a ListObjectsV2 request's own Prefix +// parameter. +func AccessControl_bucket_policy_condition_string_operators(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_string_operators" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + for _, tc := range []struct { + name string + condition string + prefix string + wantAllow bool + }{ + {"StringEquals matches", `{"StringEquals":{"s3:prefix":"photos/"}}`, "photos/", true}, + {"StringEquals mismatches", `{"StringEquals":{"s3:prefix":"photos/"}}`, "videos/", false}, + {"StringNotEquals passes on a different value", `{"StringNotEquals":{"s3:prefix":"photos/"}}`, "videos/", true}, + {"StringNotEquals fails on the same value", `{"StringNotEquals":{"s3:prefix":"photos/"}}`, "photos/", false}, + {"StringLike wildcard matches", `{"StringLike":{"s3:prefix":"photos/*"}}`, "photos/vacation", true}, + {"StringLike wildcard mismatches", `{"StringLike":{"s3:prefix":"photos/*"}}`, "videos/vacation", false}, + {"StringNotLike passes when the pattern doesn't match", `{"StringNotLike":{"s3:prefix":"photos/*"}}`, "videos/vacation", true}, + {"StringNotLike fails when the pattern matches", `{"StringNotLike":{"s3:prefix":"photos/*"}}`, "photos/vacation", false}, + {"StringEqualsIgnoreCase matches regardless of case", `{"StringEqualsIgnoreCase":{"s3:prefix":"Photos/"}}`, "photos/", true}, + {"StringNotEqualsIgnoreCase fails when equal regardless of case", `{"StringNotEqualsIgnoreCase":{"s3:prefix":"Photos/"}}`, "photos/", false}, + } { + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(tc.condition), + }); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr(tc.prefix), + }) + cancel() + + if tc.wantAllow { + if err != nil { + return fmt.Errorf("%s: expected success, got %w", tc.name, err) + } + continue + } + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// AccessControl_bucket_policy_condition_numeric_operators covers the full +// Numeric family against s3:max-keys, populated from a ListObjectsV2 +// request's own MaxKeys parameter, binding to the request's actual +// MaxKeys value, not some default. +func AccessControl_bucket_policy_condition_numeric_operators(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_numeric_operators" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + for _, tc := range []struct { + name string + condition string + maxKeys int32 + wantAllow bool + }{ + {"NumericEquals matches", `{"NumericEquals":{"s3:max-keys":"5"}}`, 5, true}, + {"NumericEquals mismatches", `{"NumericEquals":{"s3:max-keys":"5"}}`, 6, false}, + {"NumericNotEquals passes on a different value", `{"NumericNotEquals":{"s3:max-keys":"5"}}`, 6, true}, + {"NumericNotEquals fails on the same value", `{"NumericNotEquals":{"s3:max-keys":"5"}}`, 5, false}, + {"NumericLessThan matches", `{"NumericLessThan":{"s3:max-keys":"10"}}`, 5, true}, + {"NumericLessThan boundary does not match", `{"NumericLessThan":{"s3:max-keys":"10"}}`, 10, false}, + {"NumericLessThanEquals boundary matches", `{"NumericLessThanEquals":{"s3:max-keys":"10"}}`, 10, true}, + {"NumericGreaterThan matches", `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, 10, true}, + {"NumericGreaterThan boundary does not match", `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, 5, false}, + {"NumericGreaterThanEquals boundary matches", `{"NumericGreaterThanEquals":{"s3:max-keys":"5"}}`, 5, true}, + } { + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(tc.condition), + }); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + MaxKeys: getPtr(tc.maxKeys), + }) + cancel() + + if tc.wantAllow { + if err != nil { + return fmt.Errorf("%s: expected success, got %w", tc.name, err) + } + continue + } + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// AccessControl_bucket_policy_condition_date_operators covers the +// Less/Greater halves of the Date family against aws:CurrentTime, using +// dates 48 hours in the past/future so the outcome is never flaky +// regardless of test-runner clock skew or how long the request takes. +// DateEquals/DateNotEquals aren't covered here - matching an exact instant +// against a live "now" is inherently flaky at this layer - but are +// exercised at internal/condition's unit-test layer +// (TestEvaluateConditionDate), which is what actually implements the +// comparison; only the request-to-context wiring is new here. +func AccessControl_bucket_policy_condition_date_operators(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_date_operators" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + past := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339) + future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) + + for _, tc := range []struct { + name string + condition string + wantAllow bool + }{ + {"DateLessThan a future date matches", fmt.Sprintf(`{"DateLessThan":{"aws:CurrentTime":%q}}`, future), true}, + {"DateLessThan a past date does not match", fmt.Sprintf(`{"DateLessThan":{"aws:CurrentTime":%q}}`, past), false}, + {"DateLessThanEquals a future date matches", fmt.Sprintf(`{"DateLessThanEquals":{"aws:CurrentTime":%q}}`, future), true}, + {"DateGreaterThan a past date matches", fmt.Sprintf(`{"DateGreaterThan":{"aws:CurrentTime":%q}}`, past), true}, + {"DateGreaterThan a future date does not match", fmt.Sprintf(`{"DateGreaterThan":{"aws:CurrentTime":%q}}`, future), false}, + {"DateGreaterThanEquals a past date matches", fmt.Sprintf(`{"DateGreaterThanEquals":{"aws:CurrentTime":%q}}`, past), true}, + } { + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(tc.condition), + }); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + + if tc.wantAllow { + if err != nil { + return fmt.Errorf("%s: expected success, got %w", tc.name, err) + } + continue + } + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// AccessControl_bucket_policy_condition_bool_operator covers Bool against +// aws:SecureTransport, comparing it to whether s's own endpoint is actually +// using TLS - so this passes the same way against either an HTTP or HTTPS +// test target. +func AccessControl_bucket_policy_condition_bool_operator(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_bool_operator" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + secure := strings.HasPrefix(s.endpoint, "https://") + + for _, tc := range []struct { + name string + condition string + wantAllow bool + }{ + {"Bool matches the request's actual transport", fmt.Sprintf(`{"Bool":{"aws:SecureTransport":"%t"}}`, secure), true}, + {"Bool mismatches the request's actual transport", fmt.Sprintf(`{"Bool":{"aws:SecureTransport":"%t"}}`, !secure), false}, + } { + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(tc.condition), + }); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + + if tc.wantAllow { + if err != nil { + return fmt.Errorf("%s: expected success, got %w", tc.name, err) + } + continue + } + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// AccessControl_bucket_policy_condition_binary_operator covers BinaryEquals +// against s3:prefix: AWS's own condition-operator reference documents the +// match as a literal string comparison between the policy's base64 text and +// the request context value +func AccessControl_bucket_policy_condition_binary_operator(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_binary_operator" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + encoded := base64.StdEncoding.EncodeToString([]byte("photos/")) + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(fmt.Sprintf(`{"BinaryEquals":{"s3:prefix":%q}}`, encoded)), + }); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr(encoded), + }) + cancel() + if err != nil { + return fmt.Errorf("matching prefix: expected success, got %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr("photos/"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} + +// AccessControl_bucket_policy_condition_null_operator covers Null against +// s3:prefix's presence/absence: Null:"true" requires the key be absent (no +// Prefix parameter on the request at all), Null:"false" requires it be +// present. +func AccessControl_bucket_policy_condition_null_operator(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_null_operator" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + userClient := s.getUserClient(testuser) + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(`{"Null":{"s3:prefix":"true"}}`), + }); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err != nil { + return fmt.Errorf("null:true, no prefix param: expected success, got %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr("photos/"), + }) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("null:true, prefix param present: %w", err) + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:ListBucket", + Resource: fmt.Sprintf("arn:aws:s3:::%s", bucket), + Condition: json.RawMessage(`{"Null":{"s3:prefix":"false"}}`), + }); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: &bucket, + Prefix: getPtr("photos/"), + }) + cancel() + if err != nil { + return fmt.Errorf("null:false, prefix param present: expected success, got %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = userClient.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("null:false, no prefix param: %w", err) + } + return nil + }) +} + +// AccessControl_bucket_policy_condition_not_ip_address_allow and +// ..._deny cover NotIpAddress, the negated counterpart of the IpAddress +// coverage above (AccessControl_bucket_policy_condition_ip_allow/ +// ..._ip_deny_no_match): it grants access when the caller's address falls +// OUTSIDE the given range. +func AccessControl_bucket_policy_condition_not_ip_address_allow(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_not_ip_address_allow" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + // TEST-NET-3 (RFC 5737) can never match a real caller, so + // NotIpAddress against it is always true. + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + Condition: json.RawMessage(`{"NotIpAddress":{"aws:SourceIp":"203.0.113.0/24"}}`), + }); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + _, err := putObjects(userClient, []string{"my-obj"}, bucket) + return err + }) +} + +func AccessControl_bucket_policy_condition_not_ip_address_deny(s *S3Conf) error { + testName := "AccessControl_bucket_policy_condition_not_ip_address_deny" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + // 0.0.0.0/0 matches any IPv4 address, so NotIpAddress against it is + // always false. + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + Condition: json.RawMessage(`{"NotIpAddress":{"aws:SourceIp":"0.0.0.0/0"}}`), + }); err != nil { + return err + } + + userClient := s.getUserClient(testuser) + _, err := putObjects(userClient, []string{"my-obj"}, bucket) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} diff --git a/tests/integration/DeleteObjects.go b/tests/integration/DeleteObjects.go index db22d08e..71ad96aa 100644 --- a/tests/integration/DeleteObjects.go +++ b/tests/integration/DeleteObjects.go @@ -18,8 +18,10 @@ import ( "context" "fmt" + "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/versity/versitygw/s3err" ) func DeleteObjects_empty_input(s *S3Conf) error { @@ -156,3 +158,194 @@ func DeleteObjects_success(s *S3Conf) error { return nil }) } + +// DeleteObjects_iam_mixed_denials_and_success covers a single batch mixing +// every DeleteObjects outcome at once: a key the identity policy denies, a +// governance-locked key with no bypass, and keys the caller may freely +// delete. All three outcomes land in one response — no top-level error — +// with Deleted and Errors each preserving the order the keys were +// requested in. +func DeleteObjects_iam_mixed_denials_and_success(s *S3Conf) error { + testName := "DeleteObjects_iam_mixed_denials_and_success" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"allowed/one", "allowed/two", "denied/one"} { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: &key}) + cancel() + if err != nil { + return err + } + } + if err := putGovernanceLockedObject(s, bucket, "locked/one"); err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3DeleteObject, + Resource: []string{objectArn(bucket, "allowed/*"), objectArn(bucket, "locked/*")}, + }), + }) + if err != nil { + return err + } + defer cleanup() + + delObjects := []types.ObjectIdentifier{ + {Key: getPtr("allowed/one")}, + {Key: getPtr("denied/one")}, + {Key: getPtr("locked/one")}, + {Key: getPtr("allowed/two")}, + } + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{Objects: delObjects}, + }) + cancel() + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) + } + + if err := checkDeletedKeysInOrder(out.Deleted, []string{"allowed/one", "allowed/two"}); err != nil { + return err + } + return checkDeleteObjectsErrsInOrder(out.Errors, []struct { + key string + err s3err.S3Error + }{ + {"denied/one", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "denied/one"))}, + {"locked/one", s3err.GetAPIError(s3err.ErrObjectLocked)}, + }) + }, withLock()) +} + +// DeleteObjects_iam_all_access_denied covers a batch where the identity +// policy grants nothing at all: the call itself still succeeds — no +// top-level error — with every object reported denied in Errors, in +// request order, and nothing in Deleted. +func DeleteObjects_iam_all_access_denied(s *S3Conf) error { + testName := "DeleteObjects_iam_all_access_denied" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"one", "two", "three"} { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: &key}) + cancel() + if err != nil { + return err + } + } + + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + delObjects := []types.ObjectIdentifier{ + {Key: getPtr("one")}, {Key: getPtr("two")}, {Key: getPtr("three")}, + } + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{Objects: delObjects}, + }) + cancel() + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) + } + + if len(out.Deleted) != 0 { + return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted) + } + return checkDeleteObjectsErrsInOrder(out.Errors, []struct { + key string + err s3err.S3Error + }{ + {"one", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "one"))}, + {"two", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "two"))}, + {"three", wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "three"))}, + }) + }) +} + +// DeleteObjects_iam_all_locked covers a batch where every object is +// governance-locked and none is deleted: the call still succeeds — no +// top-level error — with every object reported denied in Errors, in +// request order, and nothing in Deleted. Omitting the bypass header +// entirely reports the generic object-lock message; sending the header +// without s3:BypassGovernanceRetention reports the specific AccessDenied +// naming that action instead — the same distinction the single-object +// DELETE path makes, now confirmed for the batch path too. +func DeleteObjects_iam_all_locked(s *S3Conf) error { + testName := "DeleteObjects_iam_all_locked" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"locked/one", "locked/two"} { + if err := putGovernanceLockedObject(s, bucket, key); err != nil { + return err + } + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + delObjects := []types.ObjectIdentifier{{Key: getPtr("locked/one")}, {Key: getPtr("locked/two")}} + + // No bypass header at all: the generic object-lock message. + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{Objects: delObjects}, + }) + cancel() + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) + } + if len(out.Deleted) != 0 { + return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted) + } + if err := checkDeleteObjectsErrsInOrder(out.Errors, []struct { + key string + err s3err.S3Error + }{ + {"locked/one", s3err.GetAPIError(s3err.ErrObjectLocked)}, + {"locked/two", s3err.GetAPIError(s3err.ErrObjectLocked)}, + }); err != nil { + return fmt.Errorf("without bypass header: %w", err) + } + + // Bypass header sent, but the identity policy doesn't grant + // s3:BypassGovernanceRetention: a specific AccessDenied naming that + // action, not the generic object-lock message. + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + out, err = user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{Objects: delObjects}, + BypassGovernanceRetention: getPtr(true), + }) + cancel() + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with per-object denials, not fail outright: %w", err) + } + if len(out.Deleted) != 0 { + return fmt.Errorf("expected nothing deleted, got %+v", out.Deleted) + } + if err := checkDeleteObjectsErrsInOrder(out.Errors, []struct { + key string + err s3err.S3Error + }{ + {"locked/one", wantImplicitDeny(user.arn, actS3BypassGovernance, objectArn(bucket, "locked/one"))}, + {"locked/two", wantImplicitDeny(user.arn, actS3BypassGovernance, objectArn(bucket, "locked/two"))}, + }); err != nil { + return fmt.Errorf("with bypass header, no permission: %w", err) + } + return nil + }, withLock()) +} diff --git a/tests/integration/GetObjectRetention.go b/tests/integration/GetObjectRetention.go index d0ff789b..b454492e 100644 --- a/tests/integration/GetObjectRetention.go +++ b/tests/integration/GetObjectRetention.go @@ -113,7 +113,7 @@ func GetObjectRetention_success(s *S3Conf) error { return err } - date := time.Now().Add(time.Hour * 3) + date := time.Now().Add(complianceTestRetention) retention := types.ObjectLockRetention{ Mode: types.ObjectLockRetentionModeCompliance, RetainUntilDate: &date, diff --git a/tests/integration/PutBucketPolicy.go b/tests/integration/PutBucketPolicy.go index 7dd7e6cc..a9d4ecaa 100644 --- a/tests/integration/PutBucketPolicy.go +++ b/tests/integration/PutBucketPolicy.go @@ -523,7 +523,9 @@ func PutBucketPolicy_explicit_deny(s *S3Conf) error { Key: getPtr("someprefix/hello"), }) cancel() - if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + if err := checkApiErr(err, s3err.GetExplicitDenyAccessErr( + testuser2.access, "s3:PutObject", fmt.Sprintf("%v/someprefix/hello", resource), "a resource-based policy", + )); err != nil { return err } @@ -717,3 +719,141 @@ func PutBucketPolicy_status(s *S3Conf) error { return nil }) } + +func PutBucketPolicy_condition_invalid_operator(s *S3Conf) error { + testName := "PutBucketPolicy_condition_invalid_operator" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, tc := range []struct { + condition string + // operatorName is the exact, as-written operator name AWS's + // "Invalid Condition type : " message echoes back. + operatorName string + }{ + // completely unrecognized operator + {`{"NotARealOperator":{"aws:SourceIp":"1.2.3.4/32"}}`, "NotARealOperator"}, + // operator names are case-sensitive - lowercase is unrecognized + {`{"stringequals":{"aws:UserAgent":"foo"}}`, "stringequals"}, + // IfExists suffix on an operator that doesn't take one + {`{"NullIfExists":{"aws:UserAgent":"true"}}`, "NullIfExists"}, + // unrecognized ForAllValues/ForAnyValue qualifier prefix + {`{"ForSomeValues:StringEquals":{"aws:UserAgent":"foo"}}`, "ForSomeValues:StringEquals"}, + } { + doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject", + "Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, bucket, tc.condition) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &doc, + }) + cancel() + + if err := checkApiErr(err, getMalformedPolicyError(fmt.Sprintf("Invalid Condition type : %s", tc.operatorName))); err != nil { + return err + } + } + return nil + }) +} + +func PutBucketPolicy_condition_invalid_key(s *S3Conf) error { + testName := "PutBucketPolicy_condition_invalid_key" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, condition := range []string{ + // unrecognized key under a String operator + `{"StringEquals":{"s3:FakeKeyDoesNotExist":"foo"}}`, + // unrecognized key under a Numeric operator + `{"NumericEquals":{"s3:NotARealKey":"5"}}`, + // unrecognized key under IpAddress + `{"IpAddress":{"aws:NotARealIpKey":"10.0.0.0/8"}}`, + // a plausible-looking but non-existent aws: global key + `{"StringEquals":{"aws:NotARealGlobalKey":"foo"}}`, + } { + doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject", + "Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, bucket, condition) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &doc, + }) + cancel() + + if err := checkApiErr(err, getMalformedPolicyError("Policy has an invalid condition key")); err != nil { + return err + } + } + return nil + }) +} + +func PutBucketPolicy_condition_action_mismatch(s *S3Conf) error { + testName := "PutBucketPolicy_condition_action_mismatch" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, tc := range []struct { + action string + condition string + }{ + // s3:prefix only applies to s3:ListBucket/s3:ListBucketVersions, + // not s3:GetObject. + {`"s3:GetObject"`, `{"StringEquals":{"s3:prefix":"foo"}}`}, + // ... and notably not s3:ListBucketMultipartUploads either, + // despite also being a List-shaped action. + {`"s3:ListBucketMultipartUploads"`, `{"StringEquals":{"s3:prefix":"foo"}}`}, + // s3:x-amz-acl only applies to s3:PutObject/PutBucketAcl/PutObjectAcl. + {`"s3:GetObject"`, `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`}, + // s3:VersionId only applies to the *Version* action family. + {`"s3:GetObject"`, `{"StringEquals":{"s3:VersionId":"abc123"}}`}, + // an explicit multi-action list requires every action to + // support the key, even though s3:PutObject alone would. + {`["s3:GetObject","s3:PutObject"]`, `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`}, + } { + doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":%s, + "Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, tc.action, bucket, tc.condition) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &doc, + }) + cancel() + + if err := checkApiErr(err, getMalformedPolicyError("Conditions do not apply to combination of actions and resources in statement")); err != nil { + return err + } + } + return nil + }) +} + +func PutBucketPolicy_condition_invalid_ip(s *S3Conf) error { + testName := "PutBucketPolicy_condition_invalid_ip" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, condition := range []string{ + // not an IP address at all + `{"IpAddress":{"aws:SourceIp":"not-an-ip"}}`, + // malformed CIDR notation (octet out of range, bad prefix length) + `{"IpAddress":{"aws:SourceIp":"300.1.1.1/40"}}`, + // the same bad value under NotIpAddress + `{"NotIpAddress":{"aws:SourceIp":"not-an-ip"}}`, + // the IP-format check fires regardless of which operator wraps + // the key - even Null, which has nothing to do with IP syntax. + `{"Null":{"aws:SourceIp":"true"}}`, + } { + doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject", + "Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, bucket, condition) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &doc, + }) + cancel() + + if err := checkApiErr(err, getMalformedPolicyError("Invalid IP address in Conditions")); err != nil { + return err + } + } + return nil + }) +} diff --git a/tests/integration/PutObject.go b/tests/integration/PutObject.go index 835232b2..d2622d3b 100644 --- a/tests/integration/PutObject.go +++ b/tests/integration/PutObject.go @@ -263,7 +263,7 @@ func PutObject_with_object_lock(s *S3Conf) error { testName := "PutObject_with_object_lock" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { obj := "my-obj" - retainUntilDate := time.Now().AddDate(1, 0, 0) + retainUntilDate := time.Now().Add(complianceTestRetention) _, err := putObjectWithData(10, &s3.PutObjectInput{ Bucket: &bucket, diff --git a/tests/integration/PutObjectRetention.go b/tests/integration/PutObjectRetention.go index e384ca1f..758f6576 100644 --- a/tests/integration/PutObjectRetention.go +++ b/tests/integration/PutObjectRetention.go @@ -140,13 +140,13 @@ func PutObjectRetention_invalid_mode(s *S3Conf) error { func PutObjectRetention_overwrite_compliance_mode(s *S3Conf) error { testName := "PutObjectRetention_overwrite_compliance_mode" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - date := time.Now().Add(time.Hour * 3) obj := "my-obj" _, err := putObjects(s3client, []string{obj}, bucket) if err != nil { return err } + date := time.Now().Add(complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, @@ -161,13 +161,18 @@ func PutObjectRetention_overwrite_compliance_mode(s *S3Conf) error { return err } + // A fresh date, not the one above: COMPLIANCE denies a mode switch + // unconditionally regardless of what date is requested, so this only + // needs to still be in the future by request time, not tied to what + // was stored a round trip ago. + attempted := time.Now().Add(complianceTestRetention) ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, Key: &obj, Retention: &types.ObjectLockRetention{ Mode: types.ObjectLockRetentionModeGovernance, - RetainUntilDate: &date, + RetainUntilDate: &attempted, }, }) cancel() @@ -182,13 +187,13 @@ func PutObjectRetention_overwrite_compliance_mode(s *S3Conf) error { func PutObjectRetention_overwrite_compliance_with_compliance(s *S3Conf) error { testName := "PutObjectRetention_overwrite_compliance_with_compliance" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - date := time.Now().Add(time.Hour * 200) obj := "my-obj" _, err := putObjects(s3client, []string{obj}, bucket) if err != nil { return err } + date := time.Now().Add(complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, @@ -203,7 +208,10 @@ func PutObjectRetention_overwrite_compliance_with_compliance(s *S3Conf) error { return err } - newDate := date.AddDate(2, 0, 0) + // Extending stays within complianceTestRetention's budget so the + // object can still be waited out: a COMPLIANCE retention pushed years + // into the future could never be cleaned up. + newDate := date.Add(complianceTestRetention) ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ @@ -247,7 +255,7 @@ func PutObjectRetention_overwrite_governance_with_governance(s *S3Conf) error { return err } - newDate := date.AddDate(2, 0, 0) + newDate := date.Add(time.Hour) ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ @@ -312,13 +320,13 @@ func PutObjectRetention_overwrite_governance_without_bypass_specified(s *S3Conf) func PutObjectRetention_overwrite_governance_with_permission(s *S3Conf) error { testName := "PutObjectRetention_overwrite_governance_with_permission" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - date := time.Now().Add(time.Hour * 3) obj := "my-obj" _, err := putObjects(s3client, []string{obj}, bucket) if err != nil { return err } + date := time.Now().Add(complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, @@ -346,13 +354,14 @@ func PutObjectRetention_overwrite_governance_with_permission(s *S3Conf) error { return err } + complianceDate := time.Now().Add(complianceTestRetention) ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, Key: &obj, Retention: &types.ObjectLockRetention{ Mode: types.ObjectLockRetentionModeCompliance, - RetainUntilDate: &date, + RetainUntilDate: &complianceDate, }, BypassGovernanceRetention: &bypass, }) @@ -365,10 +374,249 @@ func PutObjectRetention_overwrite_governance_with_permission(s *S3Conf) error { }, withLock()) } +func PutObjectRetention_shorten_governance_without_bypass(s *S3Conf) error { + testName := "PutObjectRetention_shorten_governance_without_bypass" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + date := time.Now().Add(time.Hour) + obj := "my-obj" + _, err := putObjects(s3client, []string{obj}, bucket) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + + shorter := time.Now().Add(complianceTestRetention / 2) + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: &shorter, + }, + }) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + return err + } + + return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: obj}}) + }, withLock()) +} + +func PutObjectRetention_shorten_governance_with_bypass(s *S3Conf) error { + testName := "PutObjectRetention_shorten_governance_with_bypass" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + date := time.Now().Add(time.Hour) + obj := "my-obj" + _, err := putObjects(s3client, []string{obj}, bucket) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + + policy := genPolicyDoc("Allow", fmt.Sprintf(`"%v"`, s.awsID), `["s3:BypassGovernanceRetention"]`, fmt.Sprintf(`"arn:aws:s3:::%v/*"`, bucket)) + bypass := true + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &policy, + }) + cancel() + if err != nil { + return err + } + + shorter := time.Now().Add(complianceTestRetention / 2) + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: &shorter, + }, + BypassGovernanceRetention: &bypass, + }) + cancel() + if err != nil { + return err + } + + return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: obj}}) + }, withLock()) +} + +func PutObjectRetention_shorten_compliance_denied(s *S3Conf) error { + testName := "PutObjectRetention_shorten_compliance_denied" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + date := time.Now().Add(complianceTestRetention) + obj := "my-obj" + _, err := putObjects(s3client, []string{obj}, bucket) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + + policy := genPolicyDoc("Allow", fmt.Sprintf(`"%v"`, s.awsID), `["s3:BypassGovernanceRetention"]`, fmt.Sprintf(`"arn:aws:s3:::%v/*"`, bucket)) + bypass := true + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &policy, + }) + cancel() + if err != nil { + return err + } + + // Recomputed fresh right before each request below, rather than once + // up front: the server rejects a RetainUntilDate that has already + // passed (InvalidArgument) before it ever gets to the object-lock + // comparison this test is exercising (ErrObjectLocked) + newShorterDate := func() time.Time { + return time.Now().Add(time.Until(date) / 2) + } + + // Neither asking to bypass nor holding the permission helps. + shorter := newShorterDate() + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &shorter, + }, + }) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + return err + } + + shorter = newShorterDate() + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &shorter, + }, + BypassGovernanceRetention: &bypass, + }) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + return err + } + + return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: obj, isCompliance: true}}) + }, withLock()) +} + +func PutObjectRetention_rewrite_same_date(s *S3Conf) error { + testName := "PutObjectRetention_rewrite_same_date" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + govObj, compObj := "my-obj-governance", "my-obj-compliance" + _, err := putObjects(s3client, []string{govObj, compObj}, bucket) + if err != nil { + return err + } + + for _, obj := range []struct { + key string + mode types.ObjectLockRetentionMode + }{ + {key: govObj, mode: types.ObjectLockRetentionModeGovernance}, + {key: compObj, mode: types.ObjectLockRetentionModeCompliance}, + } { + date := time.Now().Add(complianceTestRetention) + if obj.mode == types.ObjectLockRetentionModeGovernance { + date = time.Now().Add(time.Hour) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj.key, + Retention: &types.ObjectLockRetention{ + Mode: obj.mode, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj.key, + Retention: &types.ObjectLockRetention{ + Mode: obj.mode, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return fmt.Errorf("%v: rewriting the identical date must be allowed: %w", obj.mode, err) + } + } + + return cleanupLockedObjects(s3client, bucket, []objToDelete{ + {key: govObj}, + {key: compObj, isCompliance: true}, + }) + }, withLock()) +} + func PutObjectRetention_success(s *S3Conf) error { testName := "PutObjectRetention_success" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - date := time.Now().Add(time.Hour * 3) key := "my-obj" _, err := putObjects(s3client, []string{key}, bucket) @@ -376,6 +624,7 @@ func PutObjectRetention_success(s *S3Conf) error { return err } + date := time.Now().Add(complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, diff --git a/tests/integration/WORM_protection.go b/tests/integration/WORM_protection.go index 1a095fc0..72b12fd0 100644 --- a/tests/integration/WORM_protection.go +++ b/tests/integration/WORM_protection.go @@ -210,6 +210,58 @@ func WORMProtection_bucket_object_lock_governance_bypass_delete_multiple(s *S3Co }, withLock()) } +func WORMProtection_delete_objects_locked_object_partial_success(s *S3Conf) error { + testName := "WORMProtection_delete_objects_locked_object_partial_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + locked, unlocked := "locked-obj", "unlocked-obj" + if _, err := putObjects(s3client, []string{locked, unlocked}, bucket); err != nil { + return err + } + + date := time.Now().Add(time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &locked, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + out, err := s3client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{ + Objects: []types.ObjectIdentifier{ + {Key: &locked}, + {Key: &unlocked}, + }, + }, + }) + cancel() + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + + if len(out.Errors) != 1 { + return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) + } + if err := checkDeleteObjectsErr(out.Errors[0], locked, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + return err + } + if err := checkDeletedKeysInOrder(out.Deleted, []string{unlocked}); err != nil { + return err + } + + return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: locked, isCompliance: false}}) + }, withLock()) +} + func WORMProtection_object_lock_retention_compliance_locked(s *S3Conf) error { testName := "WORMProtection_object_lock_retention_compliance_locked" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { @@ -220,7 +272,7 @@ func WORMProtection_object_lock_retention_compliance_locked(s *S3Conf) error { return err } - date := time.Now().Add(time.Hour * 3) + date := time.Now().Add(2 * complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index b5e9350d..cf7a7487 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -44,7 +44,7 @@ func TestAuthentication(ts *TestState) { } func TestPresignedAuthentication(ts *TestState) { - ts.Run(PresignedAuth_security_token_not_supported) + ts.Run(PresignedAuth_security_token_with_permanent_credentials) ts.Run(PresignedAuth_unsupported_algorithm) ts.Run(PresignedAuth_ECDSA_not_supported) ts.Run(PresignedAuth_missing_signature_query_param) @@ -626,6 +626,10 @@ func TestPutBucketPolicy(ts *TestState) { ts.Run(PutBucketPolicy_version) ts.Run(PutBucketPolicy_success) ts.Run(PutBucketPolicy_status) + ts.Run(PutBucketPolicy_condition_invalid_operator) + ts.Run(PutBucketPolicy_condition_invalid_key) + ts.Run(PutBucketPolicy_condition_action_mismatch) + ts.Run(PutBucketPolicy_condition_invalid_ip) } func TestGetBucketPolicy(ts *TestState) { @@ -768,6 +772,10 @@ func TestPutObjectRetention(ts *TestState) { ts.Run(PutObjectRetention_overwrite_governance_with_governance) ts.Run(PutObjectRetention_overwrite_governance_without_bypass_specified) ts.Run(PutObjectRetention_overwrite_governance_with_permission) + ts.Run(PutObjectRetention_shorten_governance_without_bypass) + ts.Run(PutObjectRetention_shorten_governance_with_bypass) + ts.Run(PutObjectRetention_shorten_compliance_denied) + ts.Run(PutObjectRetention_rewrite_same_date) ts.Run(PutObjectRetention_success) } @@ -855,6 +863,7 @@ func TestWORMProtection(ts *TestState) { ts.Run(WORMProtection_bucket_object_lock_configuration_governance_mode) ts.Run(WORMProtection_bucket_object_lock_governance_bypass_delete) ts.Run(WORMProtection_bucket_object_lock_governance_bypass_delete_multiple) + ts.Run(WORMProtection_delete_objects_locked_object_partial_success) ts.Run(WORMProtection_object_lock_retention_compliance_locked) ts.Run(WORMProtection_object_lock_retention_governance_locked) ts.Run(WORMProtection_object_lock_retention_governance_bypass_overwrite_put) @@ -1554,6 +1563,67 @@ func TestIAMAccessControl(ts *TestState) { ts.Run(IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck) } +func TestS3IAMAccessControl(ts *TestState) { + ts.Run(S3IAMAccessControl_no_policy_denies) + ts.Run(S3IAMAccessControl_root_bypasses_policies) + ts.Run(S3IAMAccessControl_identity_policy_allows_without_bucket_policy) + ts.Run(S3IAMAccessControl_identity_policy_action_wildcards) + ts.Run(S3IAMAccessControl_identity_policy_resource_scoping) + ts.Run(S3IAMAccessControl_identity_policy_bucket_vs_object_arn) + ts.Run(S3IAMAccessControl_identity_policy_not_action_and_not_resource) + ts.Run(S3IAMAccessControl_identity_policy_explicit_deny_wins) + ts.Run(S3IAMAccessControl_multiple_inline_policies_combine) + ts.Run(S3IAMAccessControl_bucket_policy_allows_without_identity_policy) + ts.Run(S3IAMAccessControl_bucket_policy_explicit_deny) + ts.Run(S3IAMAccessControl_policy_combinations) + ts.Run(S3IAMAccessControl_copy_object_requires_both_sides) + ts.Run(S3IAMAccessControl_create_bucket) + ts.Run(S3IAMAccessControl_governance_bypass_sources) + ts.Run(S3IAMAccessControl_governance_without_bypass_header) + ts.Run(S3IAMAccessControl_compliance_mode_not_bypassable) + ts.Run(S3IAMAccessControl_delete_objects_authorizes_each_key) + ts.Run(S3IAMAccessControl_delete_objects_version_needs_separate_permission) + ts.Run(S3IAMAccessControl_governance_bypass_delete_objects) + ts.Run(DeleteObjects_iam_mixed_denials_and_success) + ts.Run(DeleteObjects_iam_all_access_denied) + ts.Run(DeleteObjects_iam_all_locked) + ts.Run(S3IAMAccessControl_retention_extension_needs_no_bypass) + ts.Run(S3IAMAccessControl_governance_bypass_put_object_retention) + ts.Run(S3IAMAccessControl_retention_shortening_needs_bypass) + ts.Run(S3IAMAccessControl_condition_source_ip) + ts.Run(S3IAMAccessControl_condition_negated_operator_needs_context) + ts.Run(S3IAMAccessControl_condition_request_keys) + ts.Run(S3IAMAccessControl_condition_identity_keys) + ts.Run(S3IAMAccessControl_condition_principal_tag) + ts.Run(S3IAMAccessControl_condition_on_deny_statement) + ts.Run(S3IAMAccessControl_condition_multiple_keys_anded) + ts.Run(S3IAMAccessControl_inactive_and_deleted_credentials) + ts.Run(S3IAMAccessControl_bucket_policy_unknown_principal_rejected) +} + +func TestS3IAMSessionAccessControl(ts *TestState) { + ts.Run(S3IAMSession_role_policy_allows) + ts.Run(S3IAMSession_role_without_policy_denied) + ts.Run(S3IAMSession_role_policy_explicit_deny_wins) + ts.Run(S3IAMSession_role_policy_resource_scoped) + ts.Run(S3IAMSession_session_policy_narrows_role) + ts.Run(S3IAMSession_session_policy_cannot_widen_role) + ts.Run(S3IAMSession_session_policy_explicit_deny_overrides_role) + ts.Run(S3IAMSession_role_policy_deny_overrides_session_allow) + ts.Run(S3IAMSession_session_policy_without_role_policy_denied) + ts.Run(S3IAMSession_bucket_policy_allows_without_role_policy) + ts.Run(S3IAMSession_session_policy_filters_bucket_policy_grant) + ts.Run(S3IAMSession_bucket_policy_deny_overrides_role_allow) + ts.Run(S3IAMSession_missing_and_wrong_security_token) + ts.Run(S3IAMSession_presigned_url_with_session_credentials) + ts.Run(S3IAMSession_deleted_role_denies) + ts.Run(S3IAMSession_create_bucket_via_role_policy) + ts.Run(S3IAMSession_governance_bypass_via_role_policy) + ts.Run(S3IAMSession_delete_objects_authorizes_each_key) + ts.Run(S3IAMSession_condition_identity_keys) + ts.Run(S3IAMSession_get_caller_identity_matches_s3_principal) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1616,6 +1686,18 @@ func TestAccessControl(ts *TestState) { if !ts.conf.azureTests { ts.Run(AccessControl_policy_normalizes_object_key_for_get_put_delete) } + ts.Run(AccessControl_bucket_policy_condition_ip_allow) + ts.Run(AccessControl_bucket_policy_condition_ip_deny_no_match) + ts.Run(AccessControl_bucket_policy_condition_not_ip_address_allow) + ts.Run(AccessControl_bucket_policy_condition_not_ip_address_deny) + ts.Run(AccessControl_bucket_policy_condition_explicit_deny_overrides_allow) + ts.Run(AccessControl_bucket_policy_condition_s3_prefix) + ts.Run(AccessControl_bucket_policy_condition_string_operators) + ts.Run(AccessControl_bucket_policy_condition_numeric_operators) + ts.Run(AccessControl_bucket_policy_condition_date_operators) + ts.Run(AccessControl_bucket_policy_condition_bool_operator) + ts.Run(AccessControl_bucket_policy_condition_binary_operator) + ts.Run(AccessControl_bucket_policy_condition_null_operator) } func TestPublicBuckets(ts *TestState) { @@ -1881,6 +1963,58 @@ type IntTests map[string]IntTest func GetIntTests() IntTests { return IntTests{ + "S3IAMAccessControl_retention_shortening_needs_bypass": S3IAMAccessControl_retention_shortening_needs_bypass, + "S3IAMSession_get_caller_identity_matches_s3_principal": S3IAMSession_get_caller_identity_matches_s3_principal, + "S3IAMSession_condition_identity_keys": S3IAMSession_condition_identity_keys, + "S3IAMSession_delete_objects_authorizes_each_key": S3IAMSession_delete_objects_authorizes_each_key, + "S3IAMSession_governance_bypass_via_role_policy": S3IAMSession_governance_bypass_via_role_policy, + "S3IAMSession_create_bucket_via_role_policy": S3IAMSession_create_bucket_via_role_policy, + "S3IAMSession_deleted_role_denies": S3IAMSession_deleted_role_denies, + "S3IAMSession_presigned_url_with_session_credentials": S3IAMSession_presigned_url_with_session_credentials, + "S3IAMSession_missing_and_wrong_security_token": S3IAMSession_missing_and_wrong_security_token, + "S3IAMSession_bucket_policy_deny_overrides_role_allow": S3IAMSession_bucket_policy_deny_overrides_role_allow, + "S3IAMSession_session_policy_filters_bucket_policy_grant": S3IAMSession_session_policy_filters_bucket_policy_grant, + "S3IAMSession_bucket_policy_allows_without_role_policy": S3IAMSession_bucket_policy_allows_without_role_policy, + "S3IAMSession_session_policy_without_role_policy_denied": S3IAMSession_session_policy_without_role_policy_denied, + "S3IAMSession_role_policy_deny_overrides_session_allow": S3IAMSession_role_policy_deny_overrides_session_allow, + "S3IAMSession_session_policy_explicit_deny_overrides_role": S3IAMSession_session_policy_explicit_deny_overrides_role, + "S3IAMSession_session_policy_cannot_widen_role": S3IAMSession_session_policy_cannot_widen_role, + "S3IAMSession_session_policy_narrows_role": S3IAMSession_session_policy_narrows_role, + "S3IAMSession_role_policy_resource_scoped": S3IAMSession_role_policy_resource_scoped, + "S3IAMSession_role_policy_explicit_deny_wins": S3IAMSession_role_policy_explicit_deny_wins, + "S3IAMSession_role_without_policy_denied": S3IAMSession_role_without_policy_denied, + "S3IAMSession_role_policy_allows": S3IAMSession_role_policy_allows, + "S3IAMAccessControl_retention_extension_needs_no_bypass": S3IAMAccessControl_retention_extension_needs_no_bypass, + "S3IAMAccessControl_delete_objects_authorizes_each_key": S3IAMAccessControl_delete_objects_authorizes_each_key, + "S3IAMAccessControl_delete_objects_version_needs_separate_permission": S3IAMAccessControl_delete_objects_version_needs_separate_permission, + "S3IAMAccessControl_no_policy_denies": S3IAMAccessControl_no_policy_denies, + "S3IAMAccessControl_root_bypasses_policies": S3IAMAccessControl_root_bypasses_policies, + "S3IAMAccessControl_identity_policy_allows_without_bucket_policy": S3IAMAccessControl_identity_policy_allows_without_bucket_policy, + "S3IAMAccessControl_identity_policy_action_wildcards": S3IAMAccessControl_identity_policy_action_wildcards, + "S3IAMAccessControl_identity_policy_resource_scoping": S3IAMAccessControl_identity_policy_resource_scoping, + "S3IAMAccessControl_identity_policy_bucket_vs_object_arn": S3IAMAccessControl_identity_policy_bucket_vs_object_arn, + "S3IAMAccessControl_identity_policy_not_action_and_not_resource": S3IAMAccessControl_identity_policy_not_action_and_not_resource, + "S3IAMAccessControl_identity_policy_explicit_deny_wins": S3IAMAccessControl_identity_policy_explicit_deny_wins, + "S3IAMAccessControl_multiple_inline_policies_combine": S3IAMAccessControl_multiple_inline_policies_combine, + "S3IAMAccessControl_bucket_policy_allows_without_identity_policy": S3IAMAccessControl_bucket_policy_allows_without_identity_policy, + "S3IAMAccessControl_bucket_policy_explicit_deny": S3IAMAccessControl_bucket_policy_explicit_deny, + "S3IAMAccessControl_policy_combinations": S3IAMAccessControl_policy_combinations, + "S3IAMAccessControl_copy_object_requires_both_sides": S3IAMAccessControl_copy_object_requires_both_sides, + "S3IAMAccessControl_create_bucket": S3IAMAccessControl_create_bucket, + "S3IAMAccessControl_governance_bypass_sources": S3IAMAccessControl_governance_bypass_sources, + "S3IAMAccessControl_governance_without_bypass_header": S3IAMAccessControl_governance_without_bypass_header, + "S3IAMAccessControl_compliance_mode_not_bypassable": S3IAMAccessControl_compliance_mode_not_bypassable, + "S3IAMAccessControl_governance_bypass_delete_objects": S3IAMAccessControl_governance_bypass_delete_objects, + "S3IAMAccessControl_governance_bypass_put_object_retention": S3IAMAccessControl_governance_bypass_put_object_retention, + "S3IAMAccessControl_condition_source_ip": S3IAMAccessControl_condition_source_ip, + "S3IAMAccessControl_condition_negated_operator_needs_context": S3IAMAccessControl_condition_negated_operator_needs_context, + "S3IAMAccessControl_condition_request_keys": S3IAMAccessControl_condition_request_keys, + "S3IAMAccessControl_condition_identity_keys": S3IAMAccessControl_condition_identity_keys, + "S3IAMAccessControl_condition_principal_tag": S3IAMAccessControl_condition_principal_tag, + "S3IAMAccessControl_condition_on_deny_statement": S3IAMAccessControl_condition_on_deny_statement, + "S3IAMAccessControl_condition_multiple_keys_anded": S3IAMAccessControl_condition_multiple_keys_anded, + "S3IAMAccessControl_inactive_and_deleted_credentials": S3IAMAccessControl_inactive_and_deleted_credentials, + "S3IAMAccessControl_bucket_policy_unknown_principal_rejected": S3IAMAccessControl_bucket_policy_unknown_principal_rejected, "Authentication_invalid_auth_header": Authentication_invalid_auth_header, "Authentication_unsupported_signature_version": Authentication_unsupported_signature_version, "Authentication_missing_components": Authentication_missing_components, @@ -2273,7 +2407,7 @@ func GetIntTests() IntTests { "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy": IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy, "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer": IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer, "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck": IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck, - "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, + "PresignedAuth_security_token_with_permanent_credentials": PresignedAuth_security_token_with_permanent_credentials, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, "PresignedAuth_missing_signature_query_param": PresignedAuth_missing_signature_query_param, @@ -2503,6 +2637,9 @@ func GetIntTests() IntTests { "DeleteObjects_empty_input": DeleteObjects_empty_input, "DeleteObjects_non_existing_objects": DeleteObjects_non_existing_objects, "DeleteObjects_success": DeleteObjects_success, + "DeleteObjects_iam_mixed_denials_and_success": DeleteObjects_iam_mixed_denials_and_success, + "DeleteObjects_iam_all_access_denied": DeleteObjects_iam_all_access_denied, + "DeleteObjects_iam_all_locked": DeleteObjects_iam_all_locked, "CopyObject_non_existing_dst_bucket": CopyObject_non_existing_dst_bucket, "CopyObject_not_owned_source_bucket": CopyObject_not_owned_source_bucket, "CopyObject_copy_to_itself": CopyObject_copy_to_itself, @@ -2709,6 +2846,10 @@ func GetIntTests() IntTests { "PutBucketPolicy_version": PutBucketPolicy_version, "PutBucketPolicy_success": PutBucketPolicy_success, "PutBucketPolicy_status": PutBucketPolicy_status, + "PutBucketPolicy_condition_invalid_operator": PutBucketPolicy_condition_invalid_operator, + "PutBucketPolicy_condition_invalid_key": PutBucketPolicy_condition_invalid_key, + "PutBucketPolicy_condition_action_mismatch": PutBucketPolicy_condition_action_mismatch, + "PutBucketPolicy_condition_invalid_ip": PutBucketPolicy_condition_invalid_ip, "GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket, "GetBucketPolicy_not_set": GetBucketPolicy_not_set, "GetBucketPolicy_success": GetBucketPolicy_success, @@ -2804,6 +2945,10 @@ func GetIntTests() IntTests { "PutObjectRetention_overwrite_governance_with_governance": PutObjectRetention_overwrite_governance_with_governance, "PutObjectRetention_overwrite_governance_without_bypass_specified": PutObjectRetention_overwrite_governance_without_bypass_specified, "PutObjectRetention_overwrite_governance_with_permission": PutObjectRetention_overwrite_governance_with_permission, + "PutObjectRetention_shorten_governance_without_bypass": PutObjectRetention_shorten_governance_without_bypass, + "PutObjectRetention_shorten_governance_with_bypass": PutObjectRetention_shorten_governance_with_bypass, + "PutObjectRetention_shorten_compliance_denied": PutObjectRetention_shorten_compliance_denied, + "PutObjectRetention_rewrite_same_date": PutObjectRetention_rewrite_same_date, "PutObjectRetention_success": PutObjectRetention_success, "GetObjectRetention_non_existing_bucket": GetObjectRetention_non_existing_bucket, "GetObjectRetention_non_existing_object": GetObjectRetention_non_existing_object, @@ -2863,6 +3008,7 @@ func GetIntTests() IntTests { "WORMProtection_bucket_object_lock_configuration_governance_mode": WORMProtection_bucket_object_lock_configuration_governance_mode, "WORMProtection_bucket_object_lock_governance_bypass_delete": WORMProtection_bucket_object_lock_governance_bypass_delete, "WORMProtection_bucket_object_lock_governance_bypass_delete_multiple": WORMProtection_bucket_object_lock_governance_bypass_delete_multiple, + "WORMProtection_delete_objects_locked_object_partial_success": WORMProtection_delete_objects_locked_object_partial_success, "WORMProtection_object_lock_retention_compliance_locked": WORMProtection_object_lock_retention_compliance_locked, "WORMProtection_object_lock_retention_governance_locked": WORMProtection_object_lock_retention_governance_locked, "WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put, @@ -2912,6 +3058,18 @@ func GetIntTests() IntTests { "AccessControl_CopyObject_with_legal_hold_policy": AccessControl_CopyObject_with_legal_hold_policy, "AccessControl_CopyObject_with_retention_policy": AccessControl_CopyObject_with_retention_policy, "AccessControl_policy_normalizes_object_key_for_get_put_delete": AccessControl_policy_normalizes_object_key_for_get_put_delete, + "AccessControl_bucket_policy_condition_ip_allow": AccessControl_bucket_policy_condition_ip_allow, + "AccessControl_bucket_policy_condition_ip_deny_no_match": AccessControl_bucket_policy_condition_ip_deny_no_match, + "AccessControl_bucket_policy_condition_not_ip_address_allow": AccessControl_bucket_policy_condition_not_ip_address_allow, + "AccessControl_bucket_policy_condition_not_ip_address_deny": AccessControl_bucket_policy_condition_not_ip_address_deny, + "AccessControl_bucket_policy_condition_explicit_deny_overrides_allow": AccessControl_bucket_policy_condition_explicit_deny_overrides_allow, + "AccessControl_bucket_policy_condition_s3_prefix": AccessControl_bucket_policy_condition_s3_prefix, + "AccessControl_bucket_policy_condition_string_operators": AccessControl_bucket_policy_condition_string_operators, + "AccessControl_bucket_policy_condition_numeric_operators": AccessControl_bucket_policy_condition_numeric_operators, + "AccessControl_bucket_policy_condition_date_operators": AccessControl_bucket_policy_condition_date_operators, + "AccessControl_bucket_policy_condition_bool_operator": AccessControl_bucket_policy_condition_bool_operator, + "AccessControl_bucket_policy_condition_binary_operator": AccessControl_bucket_policy_condition_binary_operator, + "AccessControl_bucket_policy_condition_null_operator": AccessControl_bucket_policy_condition_null_operator, "PublicBucket_default_private_bucket": PublicBucket_default_private_bucket, "PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy, "PublicBucket_public_object_policy": PublicBucket_public_object_policy, diff --git a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go index 61a5600e..e218702c 100644 --- a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go +++ b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go @@ -17,6 +17,7 @@ package integration import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -27,6 +28,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/smithy-go" ) const ( @@ -142,14 +144,29 @@ func IAMAssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error { // trust policy would grant every workflow run in the repo, on any branch, // the same trust, which is far too broad outside this throwaway context. func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn string, cleanup func(), err error) { + // The provider is keyed by URL alone — a second CreateOpenIDConnectProvider + // for the same githubOIDCIssuerURL fails with EntityAlreadyExists, same as + // real AWS. Some tests mint more than one session (and so call this more + // than once) within a single run, so a provider left by an earlier call + // that hasn't been cleaned up yet is expected, not a leak: reuse it rather + // than failing, and only this call's cleanup deletes it if this call is + // the one that actually created it. + ownsProvider := true out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ Url: aws.String(githubOIDCIssuerURL), ClientIDList: []string{githubOIDCTestAudience}, }) + var providerArn string if err != nil { - return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err) + var ae smithy.APIError + if !errors.As(err, &ae) || ae.ErrorCode() != "EntityAlreadyExists" { + return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err) + } + ownsProvider = false + providerArn = oidcProviderArn(githubOIDCIssuerURL) + } else { + providerArn = aws.ToString(out.OpenIDConnectProviderArn) } - providerArn := aws.ToString(out.OpenIDConnectProviderArn) host := trimProviderScheme(githubOIDCIssuerURL) roleName = "github-oidc-" + genRandString(12) @@ -157,14 +174,18 @@ func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn s `"Condition":{"StringEquals":{"%s:aud":%q},"StringLike":{"%s:sub":%q}}}]}`, providerArn, host, githubOIDCTestAudience, host, "repo:"+repo+":*") if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { - deleteOIDCProvider(client, providerArn) + if ownsProvider { + deleteOIDCProvider(client, providerArn) + } return "", "", nil, fmt.Errorf("create GitHub OIDC trust role: %w", err) } roleArn = "arn:aws:iam::000000000000:role/" + roleName cleanup = func() { deleteIAMRole(client, roleName) - deleteOIDCProvider(client, providerArn) + if ownsProvider { + deleteOIDCProvider(client, providerArn) + } } return roleName, roleArn, cleanup, nil } @@ -233,7 +254,7 @@ func fetchGitHubIDToken(requestURL, requestToken, audience string) (string, erro func getCallerIdentityWithSessionCreds(cfg S3Conf, access, secret, token string) (*sts.GetCallerIdentityOutput, error) { cfg.awsID = access cfg.awsSecret = secret - stsCfg := cfg.Config() + stsCfg := cfg.iamConfig() stsCfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) diff --git a/tests/integration/iam_query_auth.go b/tests/integration/iam_query_auth.go index 0ab03991..c87a44ba 100644 --- a/tests/integration/iam_query_auth.go +++ b/tests/integration/iam_query_auth.go @@ -16,15 +16,12 @@ 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" ) @@ -273,30 +270,38 @@ func createIAMQuerySignedRequest(endpoint string, cfg *authConfig, access, secre 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) - } + yyyymmdd := cfg.date.Format(sigv4auth.YYYYMMDD) + derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, region, cfg.service) + in := sigv4auth.SigningInputFromRequest(req) + in.AccessKeyID = access + in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, region, cfg.service) + in.PayloadHash = payloadHash + in.SigningTime = cfg.date + in.IsPreSign = true + result := sigv4auth.BuildAndSign(derivedKey, in) - signedReq, err := http.NewRequest(cfg.method, signedURL, bytes.NewReader(cfg.body)) + signedURL := *req.URL + signedURL.RawQuery = result.RawQuery + + signedReq, err := http.NewRequest(cfg.method, signedURL.String(), 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...) + for key, values := range result.SignedHeaders { + if key == "host" { + // signedReq already carries the correct Host implicitly via its + // URL; result.SignedHeaders holds it under the raw lowercase + // canonical-header key "host" rather than Go's canonicalized + // "Host", so writing it into signedReq.Header would add a + // second, non-excluded Host header on the wire. + continue + } + for _, value := range values { + signedReq.Header.Add(key, value) + } } return signedReq, nil diff --git a/tests/integration/presigned_urls.go b/tests/integration/presigned_urls.go index 9a3dacc0..03791918 100644 --- a/tests/integration/presigned_urls.go +++ b/tests/integration/presigned_urls.go @@ -28,8 +28,8 @@ import ( "github.com/versity/versitygw/s3err" ) -func PresignedAuth_security_token_not_supported(s *S3Conf) error { - testName := "PresignedAuth_security_token_not_supported" +func PresignedAuth_security_token_with_permanent_credentials(s *S3Conf) error { + testName := "PresignedAuth_security_token_with_permanent_credentials" return presignedAuthHandler(s, testName, func(client *s3.PresignClient, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) v4req, err := client.PresignDeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: &bucket}) @@ -50,7 +50,7 @@ func PresignedAuth_security_token_not_supported(s *S3Conf) error { return err } - return checkHTTPResponseApiErr(resp, s3err.QueryAuthErrors.SecurityTokenNotSupported()) + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrInvalidToken)) }) } diff --git a/tests/integration/s3_iam_access_control.go b/tests/integration/s3_iam_access_control.go new file mode 100644 index 00000000..6a725038 --- /dev/null +++ b/tests/integration/s3_iam_access_control.go @@ -0,0 +1,1805 @@ +// 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" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +// S3IAMAccessControl_no_policy_denies verifies a caller with no identity +// policy and no bucket policy is denied by default — there is no implicit +// grant anywhere for an ordinary IAM user. +func S3IAMAccessControl_no_policy_denies(s *S3Conf) error { + testName := "S3IAMAccessControl_no_policy_denies" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_root_bypasses_policies verifies the gateway's root +// credential is authorized regardless of any policy, including a bucket +// policy that explicitly denies everyone. +func S3IAMAccessControl_root_bypasses_policies(s *S3Conf) error { + testName := "S3IAMAccessControl_root_bypasses_policies" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Deny", Principal: "*", Action: "s3:*", Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return err + }) +} + +// S3IAMAccessControl_identity_policy_allows_without_bucket_policy is the +// core same-account behavior: an identity-policy Allow grants the request on +// its own, with no bucket policy and no ACL grant involved at all. +func S3IAMAccessControl_identity_policy_allows_without_bucket_policy(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_allows_without_bucket_policy" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected PutObject to be allowed: %w", err) + } + + // The same policy grants nothing beyond the action it names. + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_identity_policy_action_wildcards verifies "s3:*" and +// prefix wildcards ("s3:Get*") match the way an exact action name does. +func S3IAMAccessControl_identity_policy_action_wildcards(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_action_wildcards" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + cases := []struct { + name string + action any + wantGetOK bool + wantPutOK bool + }{ + {name: "full wildcard", action: "s3:*", wantGetOK: true, wantPutOK: true}, + {name: "prefix wildcard", action: "s3:Get*", wantGetOK: true, wantPutOK: false}, + {name: "bare wildcard", action: "*", wantGetOK: true, wantPutOK: true}, + } + for _, tc := range cases { + if err := func() error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: tc.action, + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantGetOK { + if err != nil { + return fmt.Errorf("expected GetObject to be allowed: %w", err) + } + } else if err := checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj2")}) + cancel() + if tc.wantPutOK { + if err != nil { + return fmt.Errorf("expected PutObject to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(user.arn, actS3PutObject, objectArn(bucket, "obj2"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_identity_policy_resource_scoping verifies a Resource +// pattern scopes a grant to matching keys only. +func S3IAMAccessControl_identity_policy_resource_scoping(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_resource_scoping" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"allowed/obj", "denied/obj"} { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)}) + cancel() + if err != nil { + return err + } + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, + Resource: objectArn(bucket, "allowed/*"), + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("allowed/obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject on the matching key to be allowed: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("denied/obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "denied/obj"))) + }) +} + +// S3IAMAccessControl_identity_policy_bucket_vs_object_arn verifies a +// bucket-level action evaluates against the bucket ARN, so an object-ARN +// grant ("bucket/*") does not cover it and vice versa. +func S3IAMAccessControl_identity_policy_bucket_vs_object_arn(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_bucket_vs_object_arn" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + objectOnly, cleanupObj, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanupObj() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = objectOnly.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err := checkApiErr(err, wantImplicitDeny(objectOnly.arn, actS3ListBucket, bucketArn(bucket))); err != nil { + return fmt.Errorf("an object-ARN grant must not cover a bucket-level action: %w", err) + } + + bucketScoped, cleanupBucket, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3ListBucket, Resource: bucketArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanupBucket() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = bucketScoped.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err != nil { + return fmt.Errorf("expected ListObjects to be allowed by a bucket-ARN grant: %w", err) + } + return nil + }) +} + +// S3IAMAccessControl_identity_policy_not_action_and_not_resource verifies +// NotAction and NotResource grant everything *except* what they name. +func S3IAMAccessControl_identity_policy_not_action_and_not_resource(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_not_action_and_not_resource" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + notAction, cleanupAction, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", NotAction: actS3GetObject, + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + }) + if err != nil { + return err + } + defer cleanupAction() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = notAction.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + if err != nil { + return fmt.Errorf("NotAction must grant an action it does not name: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = notAction.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err := checkApiErr(err, wantImplicitDeny(notAction.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { + return fmt.Errorf("NotAction must not grant the action it names: %w", err) + } + + notResource, cleanupResource, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, + NotResource: objectArn(bucket, "obj"), + }), + }) + if err != nil { + return err + } + defer cleanupResource() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = notResource.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + if err != nil { + return fmt.Errorf("NotResource must grant a resource it does not name: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = notResource.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(notResource.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_identity_policy_explicit_deny_wins verifies an explicit +// Deny beats a matching Allow regardless of statement order or of whether +// the two live in the same inline policy document. +func S3IAMAccessControl_identity_policy_explicit_deny_wins(s *S3Conf) error { + testName := "S3IAMAccessControl_identity_policy_explicit_deny_wins" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + allow := accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)} + deny := accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)} + + cases := []struct { + name string + policies map[string]string + }{ + {"deny after allow, same document", map[string]string{"p": policyDoc(allow, deny)}}, + {"deny before allow, same document", map[string]string{"p": policyDoc(deny, allow)}}, + {"allow and deny in separate documents", map[string]string{"a": policyDoc(allow), "d": policyDoc(deny)}}, + } + for _, tc := range cases { + if err := func() error { + user, cleanup, err := newS3IAMUser(root, s, tc.policies) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_multiple_inline_policies_combine verifies separate +// inline policy documents are unioned, so an action allowed by either one is +// allowed overall. +func S3IAMAccessControl_multiple_inline_policies_combine(s *S3Conf) error { + testName := "S3IAMAccessControl_multiple_inline_policies_combine" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "reader": policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}), + "writer": policyDoc(accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected PutObject to be allowed by the second document: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by the first document: %w", err) + } + return nil + }) +} + +// S3IAMAccessControl_bucket_policy_allows_without_identity_policy verifies +// the resource side is independently sufficient too: a bucket policy naming +// the user's access key grants the request with no identity policy at all. +func S3IAMAccessControl_bucket_policy_allows_without_identity_policy(s *S3Conf) error { + testName := "S3IAMAccessControl_bucket_policy_allows_without_identity_policy" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", Principal: user.conf.awsID, Action: actS3PutObject, Resource: objectsArn(bucket), + }); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected PutObject to be allowed by the bucket policy: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_bucket_policy_explicit_deny verifies a bucket-policy +// Deny denies on its own, and reports the resource-based-policy message. +func S3IAMAccessControl_bucket_policy_explicit_deny(s *S3Conf) error { + testName := "S3IAMAccessControl_bucket_policy_explicit_deny" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Deny", Principal: user.conf.awsID, Action: actS3GetObject, Resource: objectsArn(bucket), + }); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + // The resource-based denial names the access key, not the ARN: + // bucket-policy principals are access-key-based for every backend, + // so the gateway has no ARN in hand at that point. + return checkApiErr(err, wantExplicitResourceDeny(user.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_policy_combinations walks the full precedence matrix +// between the identity policy and the bucket policy for one action, checking +// the exact outcome and message for each of the nine combinations that +// matter. +func S3IAMAccessControl_policy_combinations(s *S3Conf) error { + testName := "S3IAMAccessControl_policy_combinations" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + const ( + silent = "silent" + allow = "allow" + deny = "deny" + ) + cases := []struct { + identity string + resource string + // wantErr builds the expected error, or is nil when the request + // must succeed. + wantErr func(user *s3IAMPrincipal) s3err.S3Error + }{ + {identity: silent, resource: silent, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) + }}, + {identity: allow, resource: silent}, + {identity: silent, resource: allow}, + {identity: allow, resource: allow}, + {identity: deny, resource: silent, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitIdentityDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) + }}, + {identity: deny, resource: allow, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitIdentityDeny(u.arn, actS3GetObject, objectArn(bucket, "obj")) + }}, + {identity: silent, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj")) + }}, + {identity: allow, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj")) + }}, + // A Deny on both sides is reported as the resource-based one: + // VerifyAccess evaluates the bucket policy first and returns + // immediately, which also saves an IAM round trip. + {identity: deny, resource: deny, wantErr: func(u *s3IAMPrincipal) s3err.S3Error { + return wantExplicitResourceDeny(u.conf.awsID, actS3GetObject, objectArn(bucket, "obj")) + }}, + } + + for _, tc := range cases { + if err := func() error { + policies := map[string]string{} + if tc.identity != silent { + effect := "Allow" + if tc.identity == deny { + effect = "Deny" + } + policies["p"] = policyDoc(accessStatement{ + Effect: effect, Action: actS3GetObject, Resource: objectsArn(bucket), + }) + } + + user, cleanup, err := newS3IAMUser(root, s, policies) + if err != nil { + return err + } + defer cleanup() + + if tc.resource == silent { + if err := deleteBucketPolicyIfAny(s, bucket); err != nil { + return err + } + } else { + effect := "Allow" + if tc.resource == deny { + effect = "Deny" + } + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: effect, Principal: user.conf.awsID, Action: actS3GetObject, Resource: objectsArn(bucket), + }); err != nil { + return err + } + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantErr == nil { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, tc.wantErr(user)) + }(); err != nil { + return fmt.Errorf("identity=%s resource=%s: %w", tc.identity, tc.resource, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_copy_object_requires_both_sides verifies a CopyObject +// is authorized against both its source (GetObject) and its destination +// (PutObject), so a policy granting only one of the two is not enough. +func S3IAMAccessControl_copy_object_requires_both_sides(s *S3Conf) error { + testName := "S3IAMAccessControl_copy_object_requires_both_sides" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("src")}) + cancel() + if err != nil { + return err + } + + cases := []struct { + name string + action any + wantAction string + wantArn string + }{ + {name: "destination only", action: actS3PutObject, wantAction: actS3GetObject, wantArn: objectArn(bucket, "src")}, + {name: "source only", action: actS3GetObject, wantAction: actS3PutObject, wantArn: objectArn(bucket, "dst")}, + } + for _, tc := range cases { + if err := func() error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: tc.action, Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: aws.String("dst"), + CopySource: aws.String(bucket + "/src"), + }) + cancel() + return checkApiErr(err, wantImplicitDeny(user.arn, tc.wantAction, tc.wantArn)) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + + // Granting both sides completes the copy. + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + if _, err := user.client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: aws.String("dst"), + CopySource: aws.String(bucket + "/src"), + }); err != nil { + return fmt.Errorf("expected CopyObject to be allowed once both sides are granted: %w", err) + } + return nil + }) +} + +// S3IAMAccessControl_create_bucket verifies s3:CreateBucket is gated by the +// identity policy alone — the bucket doesn't exist yet, so there is no +// bucket policy or ACL to consult — and that the grant is resource-scoped to +// the bucket name. +func S3IAMAccessControl_create_bucket(s *S3Conf) error { + testName := "S3IAMAccessControl_create_bucket" + return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error { + root := s.GetIAMClient() + allowedName, otherName := getBucketName(), getBucketName() + + cases := []struct { + name string + policy func() string + bucket string + wantErr func(user *s3IAMPrincipal, bucket string) s3err.S3Error + }{ + { + name: "no policy denies", + policy: func() string { return "" }, + bucket: otherName, + wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3CreateBucket, bucketArn(b)) + }, + }, + { + name: "explicit deny", + policy: func() string { + return policyDoc(accessStatement{Effect: "Deny", Action: actS3CreateBucket, Resource: "*"}) + }, + bucket: otherName, + wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { + return wantExplicitIdentityDeny(u.arn, actS3CreateBucket, bucketArn(b)) + }, + }, + { + name: "scoped grant allows the named bucket", + policy: func() string { + return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowedName)}) + }, + bucket: allowedName, + }, + { + name: "scoped grant denies another bucket", + policy: func() string { + return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowedName)}) + }, + bucket: otherName, + wantErr: func(u *s3IAMPrincipal, b string) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3CreateBucket, bucketArn(b)) + }, + }, + { + name: "wildcard grant allows any bucket", + policy: func() string { + return policyDoc(accessStatement{Effect: "Allow", Action: actS3CreateBucket, Resource: "arn:aws:s3:::*"}) + }, + bucket: otherName, + }, + } + + for _, tc := range cases { + if err := func() error { + policies := map[string]string{} + if doc := tc.policy(); doc != "" { + policies["p"] = doc + } + user, cleanup, err := newS3IAMUser(root, s, policies) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &tc.bucket}) + cancel() + + if tc.wantErr == nil { + if err != nil { + return fmt.Errorf("expected CreateBucket to be allowed: %w", err) + } + return teardown(s, tc.bucket) + } + return checkApiErr(err, tc.wantErr(user, tc.bucket)) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_governance_bypass_sources verifies s3:BypassGovernance +// Retention follows the same precedence as any other action: an Allow from +// either the identity policy or the bucket policy is enough on its own, and +// an explicit Deny from either wins over the other's Allow. +func S3IAMAccessControl_governance_bypass_sources(s *S3Conf) error { + testName := "S3IAMAccessControl_governance_bypass_sources" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + const ( + silent = "silent" + allow = "allow" + deny = "deny" + ) + cases := []struct { + identity string + resource string + wantDenied bool + }{ + {identity: allow, resource: silent}, + {identity: silent, resource: allow}, + {identity: allow, resource: allow}, + {identity: silent, resource: silent, wantDenied: true}, + {identity: deny, resource: allow, wantDenied: true}, + {identity: allow, resource: deny, wantDenied: true}, + } + + for i, tc := range cases { + if err := func() error { + key := fmt.Sprintf("locked-%d", i) + if err := putGovernanceLockedObject(s, bucket, key); err != nil { + return err + } + + // Deleting the object always needs s3:DeleteObject as well; + // only the bypass permission is what varies per case. + statements := []accessStatement{ + {Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)}, + } + if tc.identity != silent { + effect := "Allow" + if tc.identity == deny { + effect = "Deny" + } + statements = append(statements, accessStatement{ + Effect: effect, Action: actS3BypassGovernance, Resource: objectsArn(bucket), + }) + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{"p": policyDoc(statements...)}) + if err != nil { + return err + } + defer cleanup() + + if tc.resource == silent { + if err := deleteBucketPolicyIfAny(s, bucket); err != nil { + return err + } + } else { + effect := "Allow" + if tc.resource == deny { + effect = "Deny" + } + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: effect, Principal: user.conf.awsID, + Action: actS3BypassGovernance, Resource: objectsArn(bucket), + }); err != nil { + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &bucket, + Key: &key, + BypassGovernanceRetention: aws.Bool(true), + }) + cancel() + + if !tc.wantDenied { + if err != nil { + return fmt.Errorf("expected the governance-bypassing delete to be allowed: %w", err) + } + return nil + } + if err == nil { + return fmt.Errorf("expected the governance-bypassing delete to be denied") + } + // Whichever way bypass was denied, the error names the + // bypass action specifically — not the generic + // "object protected by object lock" message, which the + // gateway reserves for a request with no bypass header. + if err := checkSdkApiErr(err, "AccessDenied"); err != nil { + return err + } + if !strings.Contains(err.Error(), actS3BypassGovernance) { + return fmt.Errorf("expected the denial to name %s, got: %v", actS3BypassGovernance, err) + } + return nil + }(); err != nil { + return fmt.Errorf("identity=%s resource=%s: %w", tc.identity, tc.resource, err) + } + } + + // Release the keys still under retention: the cases that expected a + // denial left theirs locked, and teardown cannot remove those. + var locked []objToDelete + for i, tc := range cases { + if tc.wantDenied { + locked = append(locked, objToDelete{key: fmt.Sprintf("locked-%d", i)}) + } + } + return cleanupLockedObjects(s.GetClient(), bucket, locked) + }, withLock()) +} + +// S3IAMAccessControl_governance_without_bypass_header verifies the bypass +// permission is irrelevant when the request doesn't ask to bypass: the +// object stays protected, and the error is the generic object-lock one. +func S3IAMAccessControl_governance_without_bypass_header(s *S3Conf) error { + testName := "S3IAMAccessControl_governance_without_bypass_header" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + const key = "locked" + if err := putGovernanceLockedObject(s, bucket, key); err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: aws.String(key)}) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + return err + } + + return cleanupLockedObjects(s.GetClient(), bucket, []objToDelete{{key: key}}) + }, withLock()) +} + +// S3IAMAccessControl_compliance_mode_not_bypassable verifies COMPLIANCE +// retention is absolute: no identity or bucket policy can grant a bypass of +// it, unlike GOVERNANCE. +func S3IAMAccessControl_compliance_mode_not_bypassable(s *S3Conf) error { + testName := "S3IAMAccessControl_compliance_mode_not_bypassable" + return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { + const key = "compliance-locked" + retainUntil := time.Now().UTC().Add(time.Hour) + if _, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: aws.String(key), + ObjectLockMode: types.ObjectLockModeCompliance, + ObjectLockRetainUntilDate: &retainUntil, + }, s.GetClient()); err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &bucket, + Key: aws.String(key), + BypassGovernanceRetention: aws.Bool(true), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)) + }) +} + +// S3IAMAccessControl_delete_objects_authorizes_each_key verifies the batch +// DeleteObjects path authorizes s3:DeleteObject against each object's own +// ARN, the way real AWS does — a policy naming only "bucket/*" is +// sufficient — and that it supports partial success: verified live against +// real AWS (niksis02, account 792168558830), a key outside the granted +// prefix denies only that key, reported in the response's Errors list, while +// every other key in the same batch is still deleted and reported in +// Deleted. Both lists preserve the order the keys were requested in. +func S3IAMAccessControl_delete_objects_authorizes_each_key(s *S3Conf) error { + testName := "S3IAMAccessControl_delete_objects_authorizes_each_key" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"allowed/one", "allowed/two", "denied/three"} { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)}) + cancel() + if err != nil { + return err + } + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3DeleteObject, + Resource: objectArn(bucket, "allowed/*"), + }), + }) + if err != nil { + return err + } + defer cleanup() + + // A key outside the grant, mixed in with two that aren't, denies + // only that key — the request as a whole succeeds. + out, err := deleteObjectsWithBypass(user.client, bucket, "allowed/one", "denied/three", "allowed/two") + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + wantErr := wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "denied/three")) + if len(out.Errors) != 1 { + return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) + } + if err := checkDeleteObjectsErr(out.Errors[0], "denied/three", wantErr); err != nil { + return err + } + wantDeleted := []string{"allowed/one", "allowed/two"} + if err := checkDeletedKeysInOrder(out.Deleted, wantDeleted); err != nil { + return err + } + + // Every key inside the grant succeeds, with no bucket-ARN grant + // anywhere, and no per-object errors. + out, err = deleteObjectsWithBypass(user.client, bucket, "allowed/one") + if err != nil { + return fmt.Errorf("expected DeleteObjects to be allowed by an object-ARN-only grant: %w", err) + } + if len(out.Errors) != 0 { + return fmt.Errorf("expected no per-object errors, got %+v", out.Errors) + } + return nil + }) +} + +// S3IAMAccessControl_delete_objects_version_needs_separate_permission +// verifies that naming a VersionId in a DeleteObjects entry is authorized +// against s3:DeleteObjectVersion, a distinct permission from the +// s3:DeleteObject a keyed (unversioned) delete needs — verified live against +// real AWS (niksis02, account 792168558830): a policy granting only +// s3:DeleteObject denies the versioned deletes in a batch while its keyed +// deletes in the same batch still succeed, each independently, matching the +// single-object DELETE path's existing behavior for the same distinction. +// +// The denial happens at authorization, before the backend ever resolves the +// named version, so this doesn't need a real object version (and the +// gateway this test group runs against has no --versioning-dir configured +// to produce one): an arbitrary VersionId is enough to exercise the +// s3:DeleteObjectVersion check and prove the batch still partially +// succeeds. +func S3IAMAccessControl_delete_objects_version_needs_separate_permission(s *S3Conf) error { + testName := "S3IAMAccessControl_delete_objects_version_needs_separate_permission" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3DeleteObject, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + out, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{ + Objects: []types.ObjectIdentifier{ + {Key: aws.String("obj")}, + {Key: aws.String("versioned-obj"), VersionId: aws.String("some-version-id")}, + }, + }, + }) + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + + wantErr := wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "versioned-obj")) + if len(out.Errors) != 1 { + return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) + } + if err := checkDeleteObjectsErr(out.Errors[0], "versioned-obj", wantErr); err != nil { + return err + } + if len(out.Deleted) != 1 || out.Deleted[0].Key == nil || *out.Deleted[0].Key != "obj" { + return fmt.Errorf("expected the keyed delete to succeed, got %+v", out.Deleted) + } + return nil + }) +} + +// S3IAMAccessControl_governance_bypass_delete_objects verifies the batch +// DeleteObjects path enforces the bypass permission per object, the same way +// the single-object delete does. +func S3IAMAccessControl_governance_bypass_delete_objects(s *S3Conf) error { + testName := "S3IAMAccessControl_governance_bypass_delete_objects" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + const key = "locked" + if err := putGovernanceLockedObject(s, bucket, key); err != nil { + return err + } + + withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanupWithout() + + out, err := deleteObjectsWithBypass(withoutBypass.client, bucket, key) + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + if err := checkDeleteObjectsErr(out.Errors[0], key, + wantImplicitDeny(withoutBypass.arn, actS3BypassGovernance, objectArn(bucket, key))); err != nil { + return fmt.Errorf("expected DeleteObjects to be denied without the bypass permission: %w", err) + } + + withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanupWith() + + out, err = deleteObjectsWithBypass(withBypass.client, bucket, key) + if err != nil { + return fmt.Errorf("expected DeleteObjects to be allowed with the bypass permission: %w", err) + } + if len(out.Errors) != 0 { + return fmt.Errorf("expected no per-object errors, got %+v", out.Errors) + } + return nil + }, withLock()) +} + +// S3IAMAccessControl_retention_extension_needs_no_bypass verifies the +// direction of the change is what decides whether a bypass is needed: +// pushing a retention date further out only strengthens the lock, so it +// needs nothing beyond s3:PutObjectRetention — in either mode. Shortening +// is the case that needs a bypass, covered by the test below. +func S3IAMAccessControl_retention_extension_needs_no_bypass(s *S3Conf) error { + testName := "S3IAMAccessControl_retention_extension_needs_no_bypass" + return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanup() + + modes := []types.ObjectLockRetentionMode{ + types.ObjectLockRetentionModeGovernance, + types.ObjectLockRetentionModeCompliance, + } + for _, mode := range modes { + key := "extend-" + strings.ToLower(string(mode)) + retainUntil := time.Now().UTC().Add(time.Minute) + if _, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &key, + ObjectLockMode: types.ObjectLockMode(mode), + ObjectLockRetainUntilDate: &retainUntil, + }, s.GetClient()); err != nil { + return err + } + + extended := retainUntil.Add(time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := user.client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &key, + Retention: &types.ObjectLockRetention{Mode: mode, RetainUntilDate: &extended}, + }) + cancel() + if err != nil { + return fmt.Errorf("%s: expected extending a retention to need no bypass: %w", mode, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_governance_bypass_put_object_retention verifies the +// bypass permission gates weakening a GOVERNANCE retention through +// PutObjectRetention — here by switching its mode to COMPLIANCE, which the +// gateway only permits with the bypass header. +func S3IAMAccessControl_governance_bypass_put_object_retention(s *S3Conf) error { + testName := "S3IAMAccessControl_governance_bypass_put_object_retention" + return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { + retainUntil := time.Now().UTC().Add(time.Hour) + toCompliance := func(client *s3.Client, key string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: aws.String(key), + BypassGovernanceRetention: aws.Bool(true), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &retainUntil, + }, + }) + return err + } + + withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket)}), + }) + if err != nil { + return err + } + defer cleanupWithout() + + if err := putGovernanceLockedObject(s, bucket, "no-bypass"); err != nil { + return err + } + if err := toCompliance(withoutBypass.client, "no-bypass"); err == nil { + return fmt.Errorf("expected the retention mode change to be denied without the bypass permission") + } + + withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{"s3:PutObjectRetention", actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanupWith() + + if err := putGovernanceLockedObject(s, bucket, "with-bypass"); err != nil { + return err + } + if err := toCompliance(withBypass.client, "with-bypass"); err != nil { + return fmt.Errorf("expected the retention mode change to be allowed with the bypass permission: %w", err) + } + return nil + }) +} + +// S3IAMAccessControl_retention_shortening_needs_bypass verifies that moving +// a retention date earlier — weakening the lock without changing its mode — +// needs both the bypass header and s3:BypassGovernanceRetention for +// GOVERNANCE, and is refused outright for COMPLIANCE however the caller +// asks. +// +// Extending is the control: it only ever strengthens the lock, so it needs +// neither, in either mode. +func S3IAMAccessControl_retention_shortening_needs_bypass(s *S3Conf) error { + testName := "S3IAMAccessControl_retention_shortening_needs_bypass" + return s3IAMComplianceActionHandler(s, testName, func(root *iam.Client, bucket string) error { + withBypass, cleanupWith, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{"s3:PutObjectRetention", actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanupWith() + + withoutBypass, cleanupWithout, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:PutObjectRetention", Resource: objectsArn(bucket), + }), + }) + if err != nil { + return err + } + defer cleanupWithout() + + cases := []struct { + name string + mode types.ObjectLockRetentionMode + user *s3IAMPrincipal + shorten bool + sendHeader bool + // wantErr is nil when the change must be allowed. + wantErr func(user *s3IAMPrincipal, key string) s3err.S3Error + }{ + { + name: "governance extended needs nothing", + mode: types.ObjectLockRetentionModeGovernance, user: withoutBypass, + }, + { + name: "compliance extended needs nothing", + mode: types.ObjectLockRetentionModeCompliance, user: withoutBypass, + }, + { + // No header at all: the object is simply reported as locked, + // with no mention of a permission the caller never invoked. + name: "governance shortened without the bypass header", + mode: types.ObjectLockRetentionModeGovernance, user: withBypass, shorten: true, + wantErr: func(*s3IAMPrincipal, string) s3err.S3Error { + return s3err.GetAPIError(s3err.ErrObjectLocked) + }, + }, + { + // Header sent but the permission missing: the denial names + // the permission that was needed. + name: "governance shortened without the bypass permission", + mode: types.ObjectLockRetentionModeGovernance, user: withoutBypass, shorten: true, sendHeader: true, + wantErr: func(u *s3IAMPrincipal, key string) s3err.S3Error { + return wantImplicitDeny(u.arn, actS3BypassGovernance, objectArn(bucket, key)) + }, + }, + { + name: "governance shortened with header and permission", + mode: types.ObjectLockRetentionModeGovernance, user: withBypass, shorten: true, sendHeader: true, + }, + { + // COMPLIANCE is absolute: neither the header nor the + // permission can weaken it. + name: "compliance shortened even with header and permission", + mode: types.ObjectLockRetentionModeCompliance, user: withBypass, shorten: true, sendHeader: true, + wantErr: func(*s3IAMPrincipal, string) s3err.S3Error { + return s3err.GetAPIError(s3err.ErrObjectLocked) + }, + }, + } + + for i, tc := range cases { + if err := func() error { + key := fmt.Sprintf("retained-%d", i) + original := time.Now().UTC().Add(time.Hour) + if _, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &key, + ObjectLockMode: types.ObjectLockMode(tc.mode), + ObjectLockRetainUntilDate: &original, + }, s.GetClient()); err != nil { + return err + } + + want := original.Add(time.Minute) + if tc.shorten { + want = original.Add(-30 * time.Second) + } + + input := &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &key, + Retention: &types.ObjectLockRetention{ + Mode: tc.mode, + RetainUntilDate: &want, + }, + } + if tc.sendHeader { + input.BypassGovernanceRetention = aws.Bool(true) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := tc.user.client.PutObjectRetention(ctx, input) + cancel() + + if tc.wantErr == nil { + if err != nil { + return fmt.Errorf("expected the retention change to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, tc.wantErr(tc.user, key)) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_condition_source_ip verifies aws:SourceIp is populated +// from the real request, both as a grant that matches and as one that +// doesn't. +func S3IAMAccessControl_condition_source_ip(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_source_ip" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + callerIP, err := callerSourceIP(s) + if err != nil { + return err + } + + return runS3ConditionCases(root, s, bucket, "obj", []s3ConditionCase{ + { + name: "matching source ip", + condition: cond("IpAddress", "aws:SourceIp", callerIP+"/32"), + wantAllowed: true, + }, + { + name: "non-matching source ip", + condition: cond("IpAddress", "aws:SourceIp", "203.0.113.0/24"), + }, + { + name: "negated operator with a matching key", + condition: cond("NotIpAddress", "aws:SourceIp", "203.0.113.0/24"), + wantAllowed: true, + }, + }) + }) +} + +// S3IAMAccessControl_condition_negated_operator_needs_context is a +// regression test for a fail-open bug: the gateway used to send no condition +// context at all for S3 requests, and iamapi/policy treats a negated +// operator over an absent key as vacuously true — so a Deny guarded by +// NotIpAddress silently never fired, and an Allow guarded by one fired for +// everybody. With the context populated, a NotIpAddress Deny naming the +// caller's own address must actually deny. +func S3IAMAccessControl_condition_negated_operator_needs_context(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_negated_operator_needs_context" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + callerIP, err := callerSourceIP(s) + if err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, map[string]string{ + "p": policyDoc( + accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}, + accessStatement{ + Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: cond("NotIpAddress", "aws:SourceIp", "203.0.113.0/24"), + }, + ), + }) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil { + return fmt.Errorf("a NotIpAddress Deny must fire when the caller's address (%s) is outside the named range: %w", callerIP, err) + } + return nil + }) +} + +// S3IAMAccessControl_condition_request_keys covers the remaining condition +// keys the gateway derives from the request itself. +func S3IAMAccessControl_condition_request_keys(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_request_keys" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + // The integration harness always drives the gateway over plain + // HTTP or TLS, never both in one run, so aws:SecureTransport is + // asserted against whichever this run actually uses rather than + // hardcoded. + secure := strings.HasPrefix(s.endpoint, "https") + + return runS3ConditionCases(root, s, bucket, "obj", []s3ConditionCase{ + { + name: "secure transport matches the endpoint scheme", + condition: cond("Bool", "aws:SecureTransport", fmt.Sprintf("%t", secure)), + wantAllowed: true, + }, + { + name: "secure transport mismatch", + condition: cond("Bool", "aws:SecureTransport", fmt.Sprintf("%t", !secure)), + }, + { + name: "current time inside a broad window", + condition: cond("DateLessThan", "aws:CurrentTime", "2999-01-01T00:00:00Z"), + wantAllowed: true, + }, + { + name: "current time outside the window", + condition: cond("DateLessThan", "aws:CurrentTime", "2000-01-01T00:00:00Z"), + }, + { + name: "epoch time inside a broad window", + condition: cond("NumericGreaterThan", "aws:EpochTime", "1000000000"), + wantAllowed: true, + }, + { + name: "user agent is present", + condition: cond("Null", "aws:UserAgent", "false"), + wantAllowed: true, + }, + }) + }) +} + +// S3IAMAccessControl_condition_identity_keys verifies the identity-derived +// condition keys — which the IAM service fills in, since the gateway never +// learns who an access key belongs to — reach policy evaluation. +func S3IAMAccessControl_condition_identity_keys(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_identity_keys" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + cases := []struct { + name string + condition func() []byte + wantAllowed bool + }{ + { + name: "principal arn matches", + condition: func() []byte { return cond("StringEquals", "aws:PrincipalArn", user.arn) }, + wantAllowed: true, + }, + { + name: "principal arn mismatch", + condition: func() []byte { + return cond("StringEquals", "aws:PrincipalArn", "arn:aws:iam::000000000000:user/somebodyelse") + }, + }, + { + name: "username matches", + condition: func() []byte { return cond("StringEquals", "aws:username", user.name) }, + wantAllowed: true, + }, + { + name: "principal type is User", + condition: func() []byte { return cond("StringEquals", "aws:PrincipalType", "User") }, + wantAllowed: true, + }, + { + name: "principal account matches", + condition: func() []byte { return cond("StringEquals", "aws:PrincipalAccount", testAccountID) }, + wantAllowed: true, + }, + } + + for _, tc := range cases { + if err := func() error { + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: tc.condition(), + })); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantAllowed { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_condition_principal_tag verifies aws:PrincipalTag/ +// is populated from the calling user's own IAM tags, and that a tag the user +// doesn't carry is treated as absent rather than as an empty match. +func S3IAMAccessControl_condition_principal_tag(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_principal_tag" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + userName := newIAMUserName() + createOut, err := createIAMUser(root, &iam.CreateUserInput{ + UserName: aws.String(userName), + Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("storage")}}, + }) + if err != nil { + return err + } + defer deleteS3IAMUser(root, userName) + + keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + conf := *s + conf.awsID = aws.ToString(keyOut.AccessKey.AccessKeyId) + conf.awsSecret = aws.ToString(keyOut.AccessKey.SecretAccessKey) + user := &s3IAMPrincipal{name: userName, arn: aws.ToString(createOut.User.Arn), conf: conf, client: conf.GetClient()} + + cases := []struct { + name string + condition []byte + wantAllowed bool + }{ + {name: "matching tag value", condition: cond("StringEquals", "aws:PrincipalTag/team", "storage"), wantAllowed: true}, + {name: "wrong tag value", condition: cond("StringEquals", "aws:PrincipalTag/team", "networking")}, + {name: "tag the user does not carry", condition: cond("StringEquals", "aws:PrincipalTag/other", "anything")}, + {name: "absent tag reported by Null", condition: cond("Null", "aws:PrincipalTag/other", "true"), wantAllowed: true}, + } + for _, tc := range cases { + if err := func() error { + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: tc.condition, + })); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantAllowed { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_condition_on_deny_statement verifies a Condition +// attached to a Deny narrows that Deny — when the condition doesn't hold, +// the statement contributes nothing and an unconditional Allow still stands. +func S3IAMAccessControl_condition_on_deny_statement(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_on_deny_statement" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + callerIP, err := callerSourceIP(s) + if err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + allow := accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)} + + // Deny conditioned on an address the caller does not have: it must + // not fire, leaving the Allow in force. + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(allow, accessStatement{ + Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: cond("IpAddress", "aws:SourceIp", "203.0.113.0/24"), + })); err != nil { + return err + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("a Deny whose condition does not hold must not block an unconditional Allow: %w", err) + } + + // Deny conditioned on the caller's real address: it must fire and + // override the Allow. + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(allow, accessStatement{ + Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: cond("IpAddress", "aws:SourceIp", callerIP+"/32"), + })); err != nil { + return err + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMAccessControl_condition_multiple_keys_anded verifies multiple keys +// within one Condition block must all hold, while multiple values for one +// key are ORed. +func S3IAMAccessControl_condition_multiple_keys_anded(s *S3Conf) error { + testName := "S3IAMAccessControl_condition_multiple_keys_anded" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + callerIP, err := callerSourceIP(s) + if err != nil { + return err + } + + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + cases := []struct { + name string + condition []byte + wantAllowed bool + }{ + { + name: "both keys hold", + condition: condAll(map[string]map[string]any{ + "IpAddress": {"aws:SourceIp": callerIP + "/32"}, + "StringEquals": {"aws:username": user.name}, + }), + wantAllowed: true, + }, + { + name: "one key fails", + condition: condAll(map[string]map[string]any{ + "IpAddress": {"aws:SourceIp": callerIP + "/32"}, + "StringEquals": {"aws:username": "somebodyelse"}, + }), + }, + { + name: "one of several values for a key matches", + condition: cond("StringEquals", "aws:username", []string{"somebodyelse", user.name}), + wantAllowed: true, + }, + } + for _, tc := range cases { + if err := func() error { + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: tc.condition, + })); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantAllowed { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_inactive_and_deleted_credentials verifies the gateway +// stops accepting an access key as soon as the IAM service stops vouching +// for it — whether it was deactivated, deleted, or its user was removed. +func S3IAMAccessControl_inactive_and_deleted_credentials(s *S3Conf) error { + testName := "S3IAMAccessControl_inactive_and_deleted_credentials" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + grantAll := map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + } + + cases := []struct { + name string + disable func(user *s3IAMPrincipal) error + }{ + { + name: "deactivated access key", + disable: func(user *s3IAMPrincipal) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := root.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{ + UserName: aws.String(user.name), + AccessKeyId: aws.String(user.conf.awsID), + Status: iamtypes.StatusTypeInactive, + }) + return err + }, + }, + { + name: "deleted access key", + disable: func(user *s3IAMPrincipal) error { + return deleteIAMAccessKey(root, user.name, user.conf.awsID) + }, + }, + { + name: "deleted user", + disable: func(user *s3IAMPrincipal) error { + return deleteS3IAMUser(root, user.name) + }, + }, + } + + for _, tc := range cases { + if err := func() error { + user, cleanup, err := newS3IAMUser(root, s, grantAll) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err != nil { + return fmt.Errorf("expected the credential to work before being disabled: %w", err) + } + + if err := tc.disable(user); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + return checkApiErr(err, s3err.GetInvalidAccessKeyIdErr(user.conf.awsID)) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMAccessControl_bucket_policy_unknown_principal_rejected verifies +// PutBucketPolicy validates its principals against the IAM service, so a +// policy naming somebody who doesn't exist is rejected instead of being +// stored as a statement that can never match. +func S3IAMAccessControl_bucket_policy_unknown_principal_rejected(s *S3Conf) error { + testName := "S3IAMAccessControl_bucket_policy_unknown_principal_rejected" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", Principal: "AKIADOESNOTEXIST", Action: actS3GetObject, Resource: objectsArn(bucket), + }) + return checkApiErr(err, s3err.APIError{ + Code: "MalformedPolicy", + Description: "Invalid principal in policy", + HTTPStatusCode: 400, + }) + }) +} diff --git a/tests/integration/s3_iam_session_access_control.go b/tests/integration/s3_iam_session_access_control.go new file mode 100644 index 00000000..2e670229 --- /dev/null +++ b/tests/integration/s3_iam_session_access_control.go @@ -0,0 +1,786 @@ +// 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" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/versity/versitygw/s3err" +) + +// S3IAMSession_role_policy_allows verifies a session inherits the assumed +// role's inline policies, and that they are sufficient on their own with no +// bucket policy in play. +func S3IAMSession_role_policy_allows(s *S3Conf) error { + testName := "S3IAMSession_role_policy_allows" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected PutObject to be allowed by the role policy: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by the role policy: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket}) + cancel() + if err != nil { + return fmt.Errorf("expected ListObjects to be allowed by the role policy: %w", err) + } + return nil + }) +} + +// S3IAMSession_role_without_policy_denied verifies a session with no role +// policy and no bucket policy is denied, and that the denial names the +// assumed-role session ARN rather than the temporary access key. +func S3IAMSession_role_without_policy_denied(s *S3Conf) error { + testName := "S3IAMSession_role_without_policy_denied" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, nil, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_role_policy_explicit_deny_wins verifies an explicit Deny in +// the role's own policy overrides its Allow, exactly as for a long-term +// user. +func S3IAMSession_role_policy_explicit_deny_wins(s *S3Conf) error { + testName := "S3IAMSession_role_policy_explicit_deny_wins" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc( + accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}, + accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)}, + ), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected PutObject to still be allowed: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_role_policy_resource_scoped verifies a role policy's Resource +// pattern scopes what the session may touch. +func S3IAMSession_role_policy_resource_scoped(s *S3Conf) error { + testName := "S3IAMSession_role_policy_resource_scoped" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", Resource: objectArn(bucket, "allowed/*"), + }), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("allowed/obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected the in-scope key to be allowed: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("denied/obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "denied/obj"))) + }) +} + +// S3IAMSession_session_policy_narrows_role verifies a session policy +// restricts what the role would otherwise permit — the primary reason to +// pass one. +func S3IAMSession_session_policy_narrows_role(s *S3Conf) error { + testName := "S3IAMSession_session_policy_narrows_role" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + }, policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + })) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by both layers: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other"))) + }) +} + +// S3IAMSession_session_policy_cannot_widen_role verifies a session policy +// can only ever subtract: granting more than the role has does not add +// anything. +func S3IAMSession_session_policy_cannot_widen_role(s *S3Conf) error { + testName := "S3IAMSession_session_policy_cannot_widen_role" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + }), + }, policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", Resource: "*", + })) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by both layers: %w", err) + } + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other"))) + }) +} + +// S3IAMSession_session_policy_explicit_deny_overrides_role verifies an +// explicit Deny in the session policy beats the role's Allow. +func S3IAMSession_session_policy_explicit_deny_overrides_role(s *S3Conf) error { + testName := "S3IAMSession_session_policy_explicit_deny_overrides_role" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: "s3:*", + Resource: []string{bucketArn(bucket), objectsArn(bucket)}, + }), + }, policyDoc( + accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"}, + accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)}, + )) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_role_policy_deny_overrides_session_allow verifies the reverse +// direction: an explicit Deny in the role's policy is not escapable by a +// permissive session policy. +func S3IAMSession_role_policy_deny_overrides_session_allow(s *S3Conf) error { + testName := "S3IAMSession_role_policy_deny_overrides_session_allow" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc( + accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}, + accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)}, + ), + }, policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"})) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_session_policy_without_role_policy_denied verifies a session +// policy alone grants nothing: with the role carrying no policy and no +// bucket policy in play, there is nothing for it to narrow. +func S3IAMSession_session_policy_without_role_policy_denied(s *S3Conf) error { + testName := "S3IAMSession_session_policy_without_role_policy_denied" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, nil, + policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"})) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_bucket_policy_allows_without_role_policy verifies the bucket +// policy is independently sufficient for a session too, exactly as it is for +// a long-term user. +// +// The bucket policy names "*" rather than the session: this gateway matches +// bucket-policy principals against the caller's access key, and a session's +// key is ephemeral, so auth.CheckIfAccountsExist rejects one as a principal +// outright rather than let a policy come to reference a principal that stops +// existing. See bucketStatement. +func S3IAMSession_bucket_policy_allows_without_role_policy(s *S3Conf) error { + testName := "S3IAMSession_bucket_policy_allows_without_role_policy" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket), + }); err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, nil, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by the bucket policy: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other"))) + }) +} + +// S3IAMSession_session_policy_filters_bucket_policy_grant is the property +// that distinguishes a session policy from an ordinary identity policy: it +// filters *everything* the session can do, including permissions that came +// from the bucket policy rather than from the role. +// +// Verified against real AWS with a role carrying no identity policy at all, +// a bucket policy granting it both s3:GetObject and s3:PutObject, and a +// session policy allowing only s3:GetObject — the Get succeeds and the Put +// is denied. +func S3IAMSession_session_policy_filters_bucket_policy_grant(s *S3Conf) error { + testName := "S3IAMSession_session_policy_filters_bucket_policy_grant" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", Principal: "*", + Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket), + }); err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, nil, + policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)})) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed by the bucket policy within the session policy: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other"))) + }) +} + +// S3IAMSession_bucket_policy_deny_overrides_role_allow verifies a +// bucket-policy Deny beats the role's Allow for a session, and reports the +// resource-based-policy message. +func S3IAMSession_bucket_policy_deny_overrides_role_allow(s *S3Conf) error { + testName := "S3IAMSession_bucket_policy_deny_overrides_role_allow" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Deny", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket), + }); err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + // A resource-based denial names the raw access key: bucket-policy + // principals are access-key-based for every backend, so no ARN is in + // hand at that point. + return checkApiErr(err, wantExplicitResourceDeny(session.conf.awsID, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_missing_and_wrong_security_token verifies the two ways a +// session credential can be presented wrongly, each with the error real S3 +// returns for it. +func S3IAMSession_missing_and_wrong_security_token(s *S3Conf) error { + testName := "S3IAMSession_missing_and_wrong_security_token" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}), + }, "") + if err != nil { + return err + } + defer cleanup() + + // No token at all: with nothing to resolve the temporary access key + // against, it simply does not name any identity. + noToken := s3ClientWithSessionCreds(s, session.conf.awsID, session.conf.awsSecret, "") + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = noToken.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err := checkApiErr(err, s3err.GetInvalidAccessKeyIdErr(session.conf.awsID)); err != nil { + return fmt.Errorf("missing security token: %w", err) + } + + // A token that doesn't match the session it names. + wrongToken := s3ClientWithSessionCreds(s, session.conf.awsID, session.conf.awsSecret, "not-the-real-session-token") + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = wrongToken.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidToken)); err != nil { + return fmt.Errorf("wrong security token: %w", err) + } + return nil + }) +} + +// S3IAMSession_presigned_url_with_session_credentials verifies a presigned +// URL signed with temporary credentials works: the security token rides in +// the query string, where it is part of the signed canonical request. +func S3IAMSession_presigned_url_with_session_credentials(s *S3Conf) error { + testName := "S3IAMSession_presigned_url_with_session_credentials" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + presigned, err := s3.NewPresignClient(session.client).PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, Key: aws.String("obj"), + }) + cancel() + if err != nil { + return fmt.Errorf("presign: %w", err) + } + if !strings.Contains(presigned.URL, "X-Amz-Security-Token") { + return fmt.Errorf("expected the presigned URL to carry X-Amz-Security-Token") + } + + resp, err := s.httpClient.Get(presigned.URL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("expected the presigned request to succeed, got status %d", resp.StatusCode) + } + return nil + }) +} + +// S3IAMSession_deleted_role_denies verifies a session outlives its role's +// deletion as a credential — it still authenticates — but loses every +// permission the role gave it. +func S3IAMSession_deleted_role_denies(s *S3Conf) error { + testName := "S3IAMSession_deleted_role_denies" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return fmt.Errorf("expected GetObject to be allowed before the role is deleted: %w", err) + } + + if err := deleteIAMRoleAndPolicies(root, session.name); err != nil { + return fmt.Errorf("delete role: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} + +// S3IAMSession_create_bucket_via_role_policy verifies s3:CreateBucket is +// grantable to a session by its role policy, and denied without it. +func S3IAMSession_create_bucket_via_role_policy(s *S3Conf) error { + testName := "S3IAMSession_create_bucket_via_role_policy" + // The skip is checked before actionHandlerNoSetup rather than inside it, + // so a skipped run doesn't also report itself as a pass. + if _, ok := gitHubOIDCToken(); !ok { + skipF("%v: %v", testName, gitHubOIDCSkipReason) + return nil + } + + return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error { + root := s.GetIAMClient() + allowed, denied := getBucketName(), getBucketName() + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowed), + }), + }, "") + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &allowed}) + cancel() + if err != nil { + return fmt.Errorf("expected CreateBucket to be allowed for the granted name: %w", err) + } + defer teardown(s, allowed) + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &denied}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3CreateBucket, bucketArn(denied))) + }) +} + +// S3IAMSession_governance_bypass_via_role_policy verifies a session can be +// granted s3:BypassGovernanceRetention through its role, and that a session +// policy withholding it takes it away again. +func S3IAMSession_governance_bypass_via_role_policy(s *S3Conf) error { + testName := "S3IAMSession_governance_bypass_via_role_policy" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + grantAll := map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance}, + Resource: objectsArn(bucket), + }), + } + + // Role grants the bypass, session policy withholds it: denied. + withheld, cleanupWithheld, err := newGitHubSession(root, s, grantAll, + policyDoc(accessStatement{Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)})) + if err != nil { + return err + } + defer cleanupWithheld() + + if err := putGovernanceLockedObject(s, bucket, "locked-withheld"); err != nil { + return err + } + if err := deleteObjectBypassingGovernance(withheld.client, bucket, "locked-withheld"); err == nil { + return fmt.Errorf("expected the delete to be denied when the session policy withholds the bypass permission") + } + + // Role grants it and no session policy narrows it: allowed. + granted, cleanupGranted, err := newGitHubSession(root, s, grantAll, "") + if err != nil { + return err + } + defer cleanupGranted() + + if err := putGovernanceLockedObject(s, bucket, "locked-granted"); err != nil { + return err + } + if err := deleteObjectBypassingGovernance(granted.client, bucket, "locked-granted"); err != nil { + return fmt.Errorf("expected the delete to be allowed by the role's bypass grant: %w", err) + } + return nil + }, withLock()) +} + +// S3IAMSession_delete_objects_authorizes_each_key verifies the per-key +// authorization of a batch delete applies to a session's role policy too. +func S3IAMSession_delete_objects_authorizes_each_key(s *S3Conf) error { + testName := "S3IAMSession_delete_objects_authorizes_each_key" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + for _, key := range []string{"allowed/one", "denied/two"} { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)}) + cancel() + if err != nil { + return err + } + } + + session, cleanup, err := newGitHubSession(root, s, map[string]string{ + "p": policyDoc(accessStatement{ + Effect: "Allow", Action: actS3DeleteObject, Resource: objectArn(bucket, "allowed/*"), + }), + }, "") + if err != nil { + return err + } + defer cleanup() + + out, err := deleteObjectsWithBypass(session.client, bucket, "allowed/one", "denied/two") + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + if len(out.Errors) != 1 { + return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) + } + if err := checkDeleteObjectsErr(out.Errors[0], "denied/two", wantImplicitDeny(session.arn, actS3DeleteObject, objectArn(bucket, "denied/two"))); err != nil { + return err + } + + if _, err := deleteObjectsWithBypass(session.client, bucket, "allowed/one"); err != nil { + return fmt.Errorf("expected the in-scope key to be deletable: %w", err) + } + return nil + }) +} + +// S3IAMSession_condition_identity_keys verifies the identity-derived +// condition keys describe the *session*, not the underlying role: aws:userid +// carries the role id and session name, and aws:PrincipalArn the +// assumed-role ARN. +func S3IAMSession_condition_identity_keys(s *S3Conf) error { + testName := "S3IAMSession_condition_identity_keys" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if err != nil { + return err + } + + cases := []struct { + name string + condition func(session *s3IAMPrincipal) []byte + wantAllowed bool + }{ + { + name: "principal arn matches the assumed-role session", + condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", p.arn) }, + wantAllowed: true, + }, + { + name: "principal type is AssumedRole", + condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalType", "AssumedRole") }, + wantAllowed: true, + }, + { + name: "userid ends with the session name", + condition: func(p *s3IAMPrincipal) []byte { return cond("StringLike", "aws:userid", "*:"+sessionNameFor(p)) }, + wantAllowed: true, + }, + { + name: "principal arn mismatch", + condition: func(p *s3IAMPrincipal) []byte { + return cond("StringEquals", "aws:PrincipalArn", "arn:aws:sts::000000000000:assumed-role/other/other") + }, + }, + { + name: "aws:username is absent for a session", + condition: func(p *s3IAMPrincipal) []byte { return cond("Null", "aws:username", "false") }, + }, + } + + for _, tc := range cases { + if err := func() error { + session, cleanup, err := newGitHubSession(root, s, nil, "") + if err != nil { + return err + } + defer cleanup() + + if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{ + RoleName: aws.String(session.name), + PolicyName: aws.String("p"), + PolicyDocument: aws.String(policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: tc.condition(session), + })), + }); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + if tc.wantAllowed { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// S3IAMSession_get_caller_identity_matches_s3_principal verifies STS and the +// S3 data plane agree on who the session is: the ARN GetCallerIdentity +// reports is the one an S3 denial names. +func S3IAMSession_get_caller_identity_matches_s3_principal(s *S3Conf) error { + testName := "S3IAMSession_get_caller_identity_matches_s3_principal" + return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error { + session, cleanup, err := newGitHubSession(root, s, nil, "") + if err != nil { + return err + } + defer cleanup() + + callerOut, err := getCallerIdentityWithSessionCreds(*s, session.conf.awsID, session.conf.awsSecret, session.sessionToken) + if err != nil { + return fmt.Errorf("GetCallerIdentity: %w", err) + } + if aws.ToString(callerOut.Arn) != session.arn { + return fmt.Errorf("GetCallerIdentity reported Arn %q, want %q", aws.ToString(callerOut.Arn), session.arn) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")}) + cancel() + return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))) + }) +} diff --git a/tests/integration/s3_iam_utils.go b/tests/integration/s3_iam_utils.go new file mode 100644 index 00000000..c5d21b10 --- /dev/null +++ b/tests/integration/s3_iam_utils.go @@ -0,0 +1,550 @@ +// 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" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "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/aws-sdk-go-v2/service/sts" + "github.com/versity/versitygw/s3err" +) + +const ( + actS3GetObject = "s3:GetObject" + actS3PutObject = "s3:PutObject" + actS3DeleteObject = "s3:DeleteObject" + actS3DeleteObjectVersion = "s3:DeleteObjectVersion" + actS3ListBucket = "s3:ListBucket" + actS3CreateBucket = "s3:CreateBucket" + actS3BypassGovernance = "s3:BypassGovernanceRetention" +) + +// s3IAMPrincipal is an identity that can make S3 requests: an IAM user with +// a long-term access key, or an assumed-role session with temporary +// credentials. Tests assert against arn when checking a denial message, +// since the gateway names the principal by ARN once a PolicyEvaluator +// resolves it. +type s3IAMPrincipal struct { + // name is the IAM user name or, for a session, the role name. + name string + arn string + // conf is a copy of the suite's S3Conf carrying this principal's + // credentials, so tests can build additional clients (presign, STS) + // beyond the plain s3 one. + conf S3Conf + client *s3.Client + // sessionToken is set only for an assumed-role session, for the tests + // that need to build a differently-credentialed client from the same + // session (a presigned URL, an STS call, a deliberately wrong token). + sessionToken string +} + +// s3IAMActionHandler is actionHandler for the S3+IAM groups: it runs handler +// with a root-owned bucket and the root IAM client the fixtures below need, +// then tears the bucket down. Root creates every bucket and object a test +// operates on, so that what the test measures is the principal's +// authorization, never its ability to set the scene. +func s3IAMActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error, opts ...setupOpt) error { + return actionHandler(s, testName, func(_ *s3.Client, bucket string) error { + return handler(s.GetIAMClient(), bucket) + }, opts...) +} + +// s3IAMComplianceActionHandler is s3IAMActionHandler for the tests that put +// an object under COMPLIANCE retention. Such an object cannot be deleted +// before its retention expires — by anyone, with any permission, by design — +// so its bucket cannot be torn down either. +// +// Rather than fail teardown, the bucket is left behind, and its name gets a +// random suffix so that a leftover from an earlier run against the same data +// directory can't collide with this one. The shared getBucketName counter +// restarts with each test process, so without the suffix a second local run +// would fail every one of these tests with BucketAlreadyOwnedByYou. +func s3IAMComplianceActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error) error { + runF(testName) + + // Lower-cased because genRandString's charset includes capitals, which + // bucket names do not allow. + bucket := getBucketName() + "-" + strings.ToLower(genRandString(8)) + if err := setup(s, bucket, withLock()); err != nil { + failF("%v: failed to create a bucket: %v", testName, err) + return fmt.Errorf("%v: failed to create a bucket: %w", testName, err) + } + + if err := handler(s.GetIAMClient(), bucket); err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + + passF(testName) + return nil +} + +// newS3IAMUser creates an IAM user with the given inline policies +// (policyName -> document, may be nil) and one long-term access key, and +// returns a principal whose S3 client is authenticated as that user, plus a +// cleanup func removing the key, the policies, and the user. +func newS3IAMUser(root *iam.Client, s *S3Conf, policies map[string]string) (*s3IAMPrincipal, func(), error) { + userName := newIAMUserName() + + createOut, err := createIAMUser(root, &iam.CreateUserInput{UserName: aws.String(userName)}) + if err != nil { + return nil, nil, fmt.Errorf("create user: %w", err) + } + + cleanup := func() { deleteS3IAMUser(root, userName) } + + for name, doc := range policies { + if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(userName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + cleanup() + return nil, nil, fmt.Errorf("attach policy %q: %w", name, err) + } + } + + keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("create access key: %w", err) + } + + conf := *s + conf.awsID = aws.ToString(keyOut.AccessKey.AccessKeyId) + conf.awsSecret = aws.ToString(keyOut.AccessKey.SecretAccessKey) + + return &s3IAMPrincipal{ + name: userName, + arn: aws.ToString(createOut.User.Arn), + conf: conf, + client: conf.GetClient(), + }, cleanup, nil +} + +// putS3IAMUserPolicy attaches (or replaces) one inline policy on an existing +// principal, for tests that vary a policy in place across sub-cases rather +// than recreating the whole user each time. +func putS3IAMUserPolicy(root *iam.Client, principal *s3IAMPrincipal, policyName, document string) error { + _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(principal.name), + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(document), + }) + return err +} + +// deleteS3IAMUser removes every dependency DeleteUser would otherwise reject +// — inline policies and access keys — before deleting the user. The existing +// deleteIAMUserAndPolicies/deleteIAMUserAndAccessKeys helpers each cover +// only one of the two, and these fixtures always create both. +func deleteS3IAMUser(root *iam.Client, userName string) error { + polOut, err := listIAMUserPolicies(root, &iam.ListUserPoliciesInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, name := range polOut.PolicyNames { + if err := deleteIAMUserPolicy(root, userName, name); err != nil { + return err + } + } + + keyOut, err := listIAMAccessKeys(root, &iam.ListAccessKeysInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, key := range keyOut.AccessKeyMetadata { + if err := deleteIAMAccessKey(root, userName, aws.ToString(key.AccessKeyId)); err != nil { + return err + } + } + + return deleteIAMUser(root, userName) +} + +// putBucketPolicyDoc installs a bucket policy as root. Statements are built +// with bucketStatement so a test's intent stays readable and a typo becomes +// a compile error rather than a silently-malformed document. +func putBucketPolicyDoc(s *S3Conf, bucket string, statements ...bucketStatement) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + doc := bucketPolicyDoc(statements...) + _, err := s.GetClient().PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: &bucket, + Policy: &doc, + }) + return err +} + +// bucketStatement is one S3 bucket-policy statement, built as a typed value +// rather than a formatted JSON string so a test typo is a compile error. +// It mirrors accessStatement (iam_access_control.go) for identity policies; +// the difference is Principal, which bucket policies require and identity +// policies forbid. +// +// Principal is matched against the caller's raw access key by this gateway +// (auth.Principals.Contains) — deliberately not against an ARN, for +// compatibility with the non-IAM backends that have no ARNs at all. A +// long-term user is therefore named by its AKIA… access key. An assumed-role +// session cannot be named at all: its ASIA… key is ephemeral, so +// auth.IAMService.ResolveAccounts rejects it outright rather than let a bucket +// policy come to reference a principal that stops existing. Session tests +// use "*" for that reason. +type bucketStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Principal any `json:"Principal"` + Action any `json:"Action"` + Resource any `json:"Resource"` + Condition json.RawMessage `json:"Condition,omitempty"` +} + +// bucketPolicyDoc marshals statements into a complete bucket-policy +// document. Marshaling a fixed struct of strings cannot fail in practice; a +// panic here means a test itself is malformed. +func bucketPolicyDoc(statements ...bucketStatement) string { + doc := struct { + Version string `json:"Version"` + Statement []bucketStatement `json:"Statement"` + }{"2012-10-17", statements} + b, err := json.Marshal(doc) + if err != nil { + panic(fmt.Sprintf("s3_iam_utils: bucketPolicyDoc: %v", err)) + } + return string(b) +} + +// bucketArn and objectArn build the resource ARNs an S3 policy statement +// names, matching how the gateway builds the resource it evaluates against. +func bucketArn(bucket string) string { return "arn:aws:s3:::" + bucket } +func objectArn(bucket, key string) string { return "arn:aws:s3:::" + bucket + "/" + key } +func objectsArn(bucket string) string { return "arn:aws:s3:::" + bucket + "/*" } + +// wantExplicitIdentityDeny, wantExplicitResourceDeny and wantImplicitDeny +// name the three denial shapes VerifyAccess produces. All three share Code +// AccessDenied and HTTP 403 and differ only in message text, which is +// exactly why these tests assert on the full message: a test checking only +// the code could not tell an identity-policy deny from a bucket-policy one, +// and the precedence between them is the whole point of this group. +func wantExplicitIdentityDeny(principal, action, resourceArn string) s3err.S3Error { + return s3err.GetExplicitDenyAccessErr(principal, action, resourceArn, "an identity-based policy") +} + +func wantExplicitResourceDeny(principal, action, resourceArn string) s3err.S3Error { + return s3err.GetExplicitDenyAccessErr(principal, action, resourceArn, "a resource-based policy") +} + +func wantImplicitDeny(principal, action, resourceArn string) s3err.S3Error { + return s3err.GetImplicitDenyAccessErr(principal, action, resourceArn) +} + +// s3ClientWithSessionCreds builds an *s3.Client authenticated with a full +// access/secret/session-token triple, for the assumed-role session tests. +func s3ClientWithSessionCreds(s *S3Conf, access, secret, token string) *s3.Client { + conf := *s + conf.awsID = access + conf.awsSecret = secret + + cfg := conf.Config() + cfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + return s3.NewFromConfig(cfg, func(o *s3.Options) { + if s.hostStyle { + o.BaseEndpoint = &s.endpoint + o.UsePathStyle = false + } + }) +} + +// s3ConditionCase is one row of a table-driven condition test: the Condition +// block to attach to an otherwise-unconditional GetObject Allow, and whether +// it should grant. +type s3ConditionCase struct { + name string + condition []byte + wantAllowed bool +} + +// runS3ConditionCases attaches each case's condition to a fresh user's +// GetObject Allow and checks whether the resulting request is authorized. +// A failing condition voids the statement entirely, leaving nothing to +// grant — hence the implicit-deny expectation rather than an explicit one. +func runS3ConditionCases(root *iam.Client, s *S3Conf, bucket, key string, cases []s3ConditionCase) error { + user, cleanup, err := newS3IAMUser(root, s, nil) + if err != nil { + return err + } + defer cleanup() + + for _, tc := range cases { + if err := func() error { + if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{ + Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket), + Condition: tc.condition, + })); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr(key)}) + cancel() + if tc.wantAllowed { + if err != nil { + return fmt.Errorf("expected the request to be allowed: %w", err) + } + return nil + } + return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, key))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil +} + +func deleteObjectsWithBypass(client *s3.Client, bucket string, keys ...string) (*s3.DeleteObjectsOutput, error) { + objects := make([]types.ObjectIdentifier, len(keys)) + for i, key := range keys { + objects[i] = types.ObjectIdentifier{Key: aws.String(key)} + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: &bucket, + Delete: &types.Delete{Objects: objects}, + BypassGovernanceRetention: aws.Bool(true), + }) +} + +// checkDeleteObjectsErr checks one DeleteObjects response entry against the +// key and denial it's expected to carry. +func checkDeleteObjectsErr(got types.Error, wantKey string, wantErr s3err.S3Error) error { + if got.Key == nil || *got.Key != wantKey { + return fmt.Errorf("expected the per-object error to be for key %q, got %+v", wantKey, got) + } + base := wantErr.BaseError() + if got.Code == nil || *got.Code != base.Code { + return fmt.Errorf("expected error code %q for key %q, got %+v", base.Code, wantKey, got) + } + if got.Message == nil || *got.Message != base.Description { + return fmt.Errorf("expected error message %q for key %q, got %+v", base.Description, wantKey, got) + } + return nil +} + +// checkDeletedKeysInOrder checks that a DeleteObjects response's Deleted +// list names exactly wantKeys, in that order — DeleteObjects preserves the +// order objects were requested in across both the Deleted and Error lists. +func checkDeletedKeysInOrder(got []types.DeletedObject, wantKeys []string) error { + if len(got) != len(wantKeys) { + return fmt.Errorf("expected %d deleted objects %v, got %+v", len(wantKeys), wantKeys, got) + } + for i, want := range wantKeys { + if got[i].Key == nil || *got[i].Key != want { + return fmt.Errorf("expected deleted object %d to be %q, got %+v", i, want, got) + } + } + return nil +} + +// putGovernanceLockedObject writes an object under GOVERNANCE retention, as +// root, for the bypass-permission tests to then try to delete. +func putGovernanceLockedObject(s *S3Conf, bucket, key string) error { + retainUntil := time.Now().UTC().Add(time.Hour) + _, err := putObjectWithData(0, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &key, + ObjectLockMode: types.ObjectLockModeGovernance, + ObjectLockRetainUntilDate: &retainUntil, + }, s.GetClient()) + return err +} + +// deleteBucketPolicyIfAny clears the bucket policy for a sub-case that needs +// the resource side silent, tolerating there being none to delete — the +// table-driven tests reuse one bucket across cases rather than paying for a +// fresh bucket per row. +func deleteBucketPolicyIfAny(s *S3Conf, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + _, err := s.GetClient().DeleteBucketPolicy(ctx, &s3.DeleteBucketPolicyInput{Bucket: &bucket}) + if err != nil && checkSdkApiErr(err, "NoSuchBucketPolicy") == nil { + return nil + } + return err +} + +// gitHubOIDCSkipReason explains, in the skip message, why a run outside the +// OIDC workflow can't exercise any of this. +const gitHubOIDCSkipReason = "ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set " + + "(expected outside a GitHub Actions job with id-token: write permission)" + +var ( + gitHubOIDCTokenOnce sync.Once + gitHubOIDCTokenVal string + gitHubOIDCTokenOK bool +) + +// gitHubOIDCToken fetches one real ID token for the whole group and reuses +// it. Every test needs a token, and they all want the same audience and the +// same repo subject, so fetching one per test would only add round trips to +// GitHub's runtime endpoint for no additional coverage. +func gitHubOIDCToken() (string, bool) { + gitHubOIDCTokenOnce.Do(func() { + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + return + } + token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience) + if err != nil { + // The error is deliberately not propagated as a token: a fetch + // failure inside the workflow shows up as every test failing to + // assume a role, with the reason on the first one. + return + } + gitHubOIDCTokenVal, gitHubOIDCTokenOK = token, true + }) + return gitHubOIDCTokenVal, gitHubOIDCTokenOK +} + +// s3IAMSessionActionHandler is s3IAMActionHandler that first skips the test +// when no GitHub OIDC token can be minted — which is every environment but +// the one workflow holding id-token: write permission. +func s3IAMSessionActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error, opts ...setupOpt) error { + if _, ok := gitHubOIDCToken(); !ok { + skipF("%v: %v", testName, gitHubOIDCSkipReason) + return nil + } + return s3IAMActionHandler(s, testName, handler, opts...) +} + +// newGitHubSession registers a throwaway OIDC provider for GitHub Actions' +// issuer and a role trusting it, attaches rolePolicies as the role's inline +// permission policies, then assumes it with a real ID token and (when +// sessionPolicy is non-empty) an inline session policy. +// +// The returned principal's name is the role name, so a test can put another +// role policy on it or delete the role mid-test; arn is the assumed-role +// session ARN, which is what a denial message names. +func newGitHubSession(root *iam.Client, s *S3Conf, rolePolicies map[string]string, sessionPolicy string) (*s3IAMPrincipal, func(), error) { + token, ok := gitHubOIDCToken() + if !ok { + return nil, nil, fmt.Errorf("no GitHub OIDC token available") + } + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return nil, nil, fmt.Errorf("GITHUB_REPOSITORY is not set, but the OIDC token request variables are - unexpected environment") + } + + roleName, _, cleanup, err := createGitHubOIDCTrust(root, repo) + if err != nil { + return nil, nil, err + } + + for name, doc := range rolePolicies { + if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{ + RoleName: aws.String(roleName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + cleanup() + return nil, nil, fmt.Errorf("attach role policy %q: %w", name, err) + } + } + + sessionName := "s3-sess-" + genRandString(8) + out, err := assumeRoleWithWebIdentitySessionPolicy(s, roleArnFor(roleName), sessionName, token, sessionPolicy) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("AssumeRoleWithWebIdentity: %w", err) + } + + access := aws.ToString(out.Credentials.AccessKeyId) + secret := aws.ToString(out.Credentials.SecretAccessKey) + sessionToken := aws.ToString(out.Credentials.SessionToken) + + conf := *s + conf.awsID = access + conf.awsSecret = secret + + principal := &s3IAMPrincipal{ + name: roleName, + arn: aws.ToString(out.AssumedRoleUser.Arn), + conf: conf, + client: s3ClientWithSessionCreds(s, access, secret, sessionToken), + sessionToken: sessionToken, + } + // The role may already have been deleted by the test itself + // (S3IAMSession_deleted_role_denies); cleanup tolerates that. + return principal, cleanup, nil +} + +// assumeRoleWithWebIdentitySessionPolicy is assumeRoleWithWebIdentity with +// the optional inline session-policy parameter, which no other test in this +// package needs. +func assumeRoleWithWebIdentitySessionPolicy(s *S3Conf, roleArn, sessionName, token, sessionPolicy string) (*sts.AssumeRoleWithWebIdentityOutput, error) { + input := &sts.AssumeRoleWithWebIdentityInput{ + RoleArn: aws.String(roleArn), + RoleSessionName: aws.String(sessionName), + WebIdentityToken: aws.String(token), + } + if sessionPolicy != "" { + input.Policy = aws.String(sessionPolicy) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return s.GetSTSClient().AssumeRoleWithWebIdentity(ctx, input) +} + +// roleArnFor builds the ARN of a role in this gateway's single fixed +// account. +func roleArnFor(roleName string) string { + return "arn:aws:iam::" + testAccountID + ":role/" + roleName +} + +// sessionNameFor recovers the session name from an assumed-role ARN, whose +// last path element it is. +func sessionNameFor(p *s3IAMPrincipal) string { + idx := strings.LastIndex(p.arn, "/") + if idx < 0 { + return "" + } + return p.arn[idx+1:] +} + +// deleteObjectBypassingGovernance deletes one object with the +// bypass-governance-retention header set. +func deleteObjectBypassingGovernance(client *s3.Client, bucket, key string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &bucket, + Key: &key, + BypassGovernanceRetention: aws.Bool(true), + }) + return err +} diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index d47acb4d..41c5ef5b 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -38,6 +38,7 @@ type S3Conf struct { awsSecret string awsRegion string endpoint string + iamEndpoint string websiteScheme string websiteDomain string websitePort string @@ -94,6 +95,13 @@ func WithRegion(r string) Option { func WithEndpoint(e string) Option { return func(s *S3Conf) { s.endpoint = e } } + +// WithIAMEndpoint points the IAM/STS clients at a standalone IAM service +// separate from the S3 endpoint, for the test groups that drive both +// processes at once +func WithIAMEndpoint(e string) Option { + return func(s *S3Conf) { s.iamEndpoint = e } +} func WithWebsiteScheme(scheme string) Option { return func(s *S3Conf) { s.websiteScheme = scheme } } @@ -156,12 +164,22 @@ func (c *S3Conf) GetClient() *s3.Client { } func (c *S3Conf) GetIAMClient() *iam.Client { - return iam.NewFromConfig(c.Config()) + return iam.NewFromConfig(c.iamConfig()) } // GetSTSClient returns an SDK client for STS actions func (c *S3Conf) GetSTSClient() *sts.Client { - return sts.NewFromConfig(c.Config()) + return sts.NewFromConfig(c.iamConfig()) +} + +// iamConfig is Config with the base endpoint pointed at the IAM service +// when one was configured separately from the S3 endpoint. +func (c *S3Conf) iamConfig() aws.Config { + cfg := c.Config() + if c.iamEndpoint != "" { + cfg.BaseEndpoint = &c.iamEndpoint + } + return cfg } func (c *S3Conf) GetPresignClient() *s3.PresignClient { diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 48e5f63d..93f7d2cf 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -2120,7 +2120,7 @@ func checkWORMProtection(client *s3.Client, bucket, object string) error { } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) - _, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + out, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: &bucket, Delete: &types.Delete{ Objects: []types.ObjectIdentifier{ @@ -2131,7 +2131,13 @@ func checkWORMProtection(client *s3.Client, bucket, object string) error { }, }) cancel() - if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { + if err != nil { + return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err) + } + if len(out.Errors) != 1 { + return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors) + } + if err := checkDeleteObjectsErr(out.Errors[0], object, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil { return err } @@ -2841,6 +2847,18 @@ const ( maxDelObjWorkers int64 = 20 // Maximum number of concurrent delete workers maxRetryAttempts int = 3 // Maximum retries for object deletion lockWaitTime time.Duration = time.Second * 3 // Wait time for lock expiration before retrying delete + + // complianceTestRetention is how far out a test should set a COMPLIANCE + // retention it means to clean up afterwards. A COMPLIANCE retention can + // never be shortened or removed — by anyone, with any permission, which + // is the whole point of the mode — so the only way to release the object + // is to outlast it, which cleanupLockedObjects does. Long enough for the + // test body to run against a genuinely locked object, short enough to + // wait out. + complianceTestRetention time.Duration = time.Second * 10 + // maxComplianceCleanupWait bounds that wait, so a test that locks an + // object for hours fails quickly and clearly instead of hanging. + maxComplianceCleanupWait time.Duration = time.Second * 30 ) // cleanupLockedObjects removes objects from a bucket that may be protected by @@ -2889,21 +2907,28 @@ func cleanupLockedObjects(client *s3.Client, bucket string, objs []objToDelete) } } - // Apply temporary retention policy to allow deletion - // RetainUntilDate is set a few seconds in the future to handle network delays - retDate := time.Now().Add(lockWaitTime) - mode := types.ObjectLockRetentionModeGovernance + // A COMPLIANCE retention can only be waited out — it cannot be + // shortened or removed by anyone. Tests that mean to clean up + // therefore lock for complianceTestRetention, and this sleeps + // until that has passed. if obj.isCompliance { - mode = types.ObjectLockRetentionModeCompliance + return waitOutComplianceRetention(client, bucket, obj) } + // A GOVERNANCE retention can be weakened with the bypass + // permission and header, so shorten it to a few seconds out + // rather than waiting for the original date. The margin absorbs + // network delay. + retDate := time.Now().Add(lockWaitTime) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ - Bucket: &bucket, - Key: &obj.key, - VersionId: getPtr(obj.versionId), + Bucket: &bucket, + Key: &obj.key, + VersionId: getPtr(obj.versionId), + BypassGovernanceRetention: getBoolPtr(true), Retention: &types.ObjectLockRetention{ - Mode: mode, + Mode: types.ObjectLockRetentionModeGovernance, RetainUntilDate: &retDate, }, }) @@ -2930,6 +2955,71 @@ func cleanupLockedObjects(client *s3.Client, bucket string, objs []objToDelete) return eg.Wait() } +// waitOutComplianceRetention blocks until obj's retention has passed, the +// only way to release a COMPLIANCE-locked object. A retention further out +// than maxComplianceCleanupWait is reported as an error rather than waited +// on: such an object cannot be cleaned up within a test run at all, and the +// test that locked it should either use complianceTestRetention or skip +// teardown. +func waitOutComplianceRetention(client *s3.Client, bucket string, obj objToDelete) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + out, err := client.GetObjectRetention(ctx, &s3.GetObjectRetentionInput{ + Bucket: &bucket, + Key: &obj.key, + VersionId: getPtr(obj.versionId), + }) + cancel() + + // Already deleted: nothing to wait for. + if err != nil && checkSdkApiErr(err, "NoSuchKey") == nil { + return nil + } + + // No retention of its own means the object is protected only by the + // bucket's default retention. Nothing forbids giving it a short one of + // its own — an object-level retention supersedes the bucket default, and + // there is no existing retention here to weaken — so that is how such an + // object gets released. + noRetention := err != nil && checkSdkApiErr(err, "NoSuchObjectLockConfiguration") == nil + if err != nil && !noRetention { + return err + } + if !noRetention && (out.Retention == nil || out.Retention.RetainUntilDate == nil) { + noRetention = true + } + if noRetention { + retDate := time.Now().Add(lockWaitTime) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj.key, + VersionId: getPtr(obj.versionId), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &retDate, + }, + }) + cancel() + if err != nil && checkSdkApiErr(err, "NoSuchKey") != nil { + return err + } + time.Sleep(lockWaitTime) + return nil + } + + // The extra second absorbs clock skew between this process and the + // gateway, which compares the retention against its own clock. + wait := time.Until(*out.Retention.RetainUntilDate) + time.Second + if wait > maxComplianceCleanupWait { + return fmt.Errorf("object %q is under COMPLIANCE retention until %v, too far out to wait for: use complianceTestRetention, or skip teardown", + obj.key, out.Retention.RetainUntilDate) + } + if wait > 0 { + time.Sleep(wait) + } + return nil +} + type objectLockMode string const ( @@ -3735,3 +3825,21 @@ func hexBytes(s string) string { } return strings.Join(parts, " ") } + +// checkDeleteObjectsErrsInOrder checks that got names exactly the (key, +// error) pairs in want, in that order — DeleteObjects preserves the order +// objects were requested in across both the Deleted and Error lists. +func checkDeleteObjectsErrsInOrder(got []types.Error, want []struct { + key string + err s3err.S3Error +}) error { + if len(got) != len(want) { + return fmt.Errorf("expected %d per-object errors, got %d: %+v", len(want), len(got), got) + } + for i, w := range want { + if err := checkDeleteObjectsErr(got[i], w.key, w.err); err != nil { + return fmt.Errorf("error %d: %w", i, err) + } + } + return nil +} diff --git a/tests/integration/versioning.go b/tests/integration/versioning.go index 2662a45a..6fe70238 100644 --- a/tests/integration/versioning.go +++ b/tests/integration/versioning.go @@ -2629,7 +2629,7 @@ func Versioning_WORM_obj_version_locked_with_compliance_retention(s *S3Conf) err } version := objVersions[0] - rDate := time.Now().Add(time.Hour * 48) + rDate := time.Now().Add(2 * complianceTestRetention) ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ Bucket: &bucket, @@ -2820,7 +2820,7 @@ func Versioning_WORM_delete_marker_locked_object_compliance_retention(s *S3Conf) Key: &obj, Retention: &types.ObjectLockRetention{ Mode: types.ObjectLockRetentionModeCompliance, - RetainUntilDate: getPtr(time.Now().AddDate(1, 0, 0)), + RetainUntilDate: getPtr(time.Now().Add(complianceTestRetention)), }, }) cancel() diff --git a/website/handler.go b/website/handler.go index c436dfb5..1ac38e74 100644 --- a/website/handler.go +++ b/website/handler.go @@ -346,7 +346,7 @@ func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string } func (c *websiteController) getObject(ctx fiber.Ctx, bucket, key string) websiteResult { - if err := auth.VerifyPublicAccess(ctx.RequestCtx(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { + if err := auth.VerifyPublicAccess(ctx, c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { return websiteResult{ Key: key, StatusCode: statusCodeFromError(err), @@ -379,7 +379,7 @@ func (c *websiteController) getObject(ctx fiber.Ctx, bucket, key string) website } func (c *websiteController) headObject(ctx fiber.Ctx, bucket, key string) websiteResult { - if err := auth.VerifyPublicAccess(ctx.RequestCtx(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { + if err := auth.VerifyPublicAccess(ctx, c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { return websiteResult{ Key: key, StatusCode: statusCodeFromError(err), diff --git a/website/server.go b/website/server.go index a82a31cb..fb8b5a3b 100644 --- a/website/server.go +++ b/website/server.go @@ -24,14 +24,14 @@ import ( "github.com/gofiber/fiber/v3/middleware/recover" "github.com/versity/versitygw/backend" "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/s3api/middlewares" - "github.com/versity/versitygw/s3api/utils" ) // Server is the static website hosting endpoint. type Server struct { app *fiber.App - CertStorage *utils.CertStorage + CertStorage *netutil.CertStorage domain string quiet bool socketPerm os.FileMode @@ -46,7 +46,7 @@ func WithQuiet() Option { } // WithTLS sets TLS credentials. -func WithTLS(cs *utils.CertStorage) Option { +func WithTLS(cs *netutil.CertStorage) Option { return func(s *Server) { s.CertStorage = cs } } @@ -116,9 +116,9 @@ func (s *Server) ServeMultiPort(ports []string) error { var err error if s.CertStorage != nil { - ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, addrSpec, s.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: s.socketPerm}) + ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, addrSpec, s.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: s.socketPerm}) } else { - ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, addrSpec, utils.ListenerOptions{SocketPerm: s.socketPerm}) + ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, addrSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm}) } if err != nil { @@ -132,7 +132,7 @@ func (s *Server) ServeMultiPort(ports []string) error { return fmt.Errorf("failed to create any website listeners") } - finalListener := utils.NewMultiListener(listeners...) + finalListener := netutil.NewMultiListener(listeners...) return s.app.Listener(finalListener, fiber.ListenConfig{ DisableStartupMessage: true, diff --git a/webui/webserver.go b/webui/webserver.go index 92970e68..37bfd5db 100644 --- a/webui/webserver.go +++ b/webui/webserver.go @@ -26,7 +26,7 @@ import ( "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static" - "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/internal/netutil" ) // ServerConfig holds the server configuration @@ -40,7 +40,7 @@ type ServerConfig struct { // Server is the main GUI server type Server struct { app *fiber.App - CertStorage *utils.CertStorage + CertStorage *netutil.CertStorage config *ServerConfig pathPrefix string quiet bool @@ -56,7 +56,7 @@ func WithQuiet() Option { } // WithTLS sets TLS Credentials -func WithTLS(cs *utils.CertStorage) Option { +func WithTLS(cs *netutil.CertStorage) Option { return func(s *Server) { s.CertStorage = cs } } @@ -187,9 +187,9 @@ func (s *Server) ServeMultiPort(ports []string) error { var err error if s.CertStorage != nil { - ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, addrSpec, s.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: s.socketPerm}) + ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, addrSpec, s.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: s.socketPerm}) } else { - ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, addrSpec, utils.ListenerOptions{SocketPerm: s.socketPerm}) + ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, addrSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm}) } if err != nil { @@ -204,7 +204,7 @@ func (s *Server) ServeMultiPort(ports []string) error { } // Combine all listeners - finalListener := utils.NewMultiListener(listeners...) + finalListener := netutil.NewMultiListener(listeners...) return s.app.Listener(finalListener, fiber.ListenConfig{ DisableStartupMessage: true, From a4d4519ffead96fbb9415263dd5eeb60a901f5e3 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 25 Aug 2026 00:59:33 +0400 Subject: [PATCH 10/10] feat: version the private IAM protocol between gateway and standalone service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S3 gateway and the standalone IAM service exchange authorization decisions over the private endpoints, where a version skew is silently unsafe in both directions: an older service drops a request field it does not know (a `Condition` block, say) and evaluates fail-open, while an older gateway ignores a response field it does not know and misses a deny the service intended. Neither side could previously detect either case. Both peers now declare a protocol version on every exchange via the `X-Vgw-Private-Protocol` header — the gateway on each request, the service on each response, error responses included — and each refuses a peer it cannot serve safely. The service rejects a gateway below `MinClientProtocol` with a `ProtocolMismatch` code; the gateway rejects a service older than the `ProtocolVersion` it speaks, and rejects a response carrying no version at all, since no build of this protocol omits the header and something else answering on that address should not be interpreted as an IAM decision. `ParseProtocolVersion` is shared by both sides and deliberately strict: an unreadable value is a mismatch, never an assumed default. A new root-signed `/private/version` endpoint reports the protocol version, the minimum client the service will serve, and the build tag (`WithPrivateServerVersion`). It is exempt from the service's own client-version check so it can still answer a gateway the service refuses — which is how that gateway learns why. Being authenticated like every other private endpoint, it also lets the gateway's startup probe verify its own credential and its mTLS transport in the same round trip. The gateway probes it once in `NewIAMServiceStandalone` rather than discovering a skew as an opaque per-request 500. An incompatible service is fatal after a 30s window, since a gateway that cannot authorize a single request is more useful refusing to start with the reason in its log; an unreachable one is only a warning, because the two processes legitimately start in parallel and every request checks the version regardless. Only conditions that can resolve on their own are retried — a rejected credential is reported immediately. --- auth/iam.go | 2 +- auth/iam_standalone.go | 178 +++++++++++- auth/iam_standalone_test.go | 265 ++++++++++++++++++ cmd/versitygw/main.go | 4 +- embedgw/embedgw.go | 4 +- embedgw/iam.go | 55 +++- iamapi/private/errors.go | 19 +- iamapi/private/handlers.go | 14 + iamapi/private/listener.go | 13 + iamapi/private/private_test.go | 176 ++++++++++++ iamapi/private/server.go | 119 +++++++- iamapi/private/types.go | 14 + iamapi/server.go | 12 + tests/integration/presigned_urls.go | 4 + .../s3_iam_session_access_control.go | 9 +- 15 files changed, 850 insertions(+), 38 deletions(-) diff --git a/auth/iam.go b/auth/iam.go index 9d642e54..d8ec96dc 100644 --- a/auth/iam.go +++ b/auth/iam.go @@ -220,6 +220,7 @@ func New(o *Opts) (IAMService, error) { switch { case o.StandaloneIAMEndpoint != "": + fmt.Printf("initializing standalone IAM with %q\n", o.StandaloneIAMEndpoint) svc, err = NewIAMServiceStandalone(o.RootAccount, IAMServiceStandaloneConfig{ Endpoint: o.StandaloneIAMEndpoint, Access: o.StandaloneIAMAccess, @@ -231,7 +232,6 @@ func New(o *Opts) (IAMService, error) { DefaultGroupID: o.StandaloneDefaultGroupID, DefaultProjectID: o.StandaloneDefaultProjectID, }) - fmt.Printf("initializing standalone IAM with %q\n", o.StandaloneIAMEndpoint) if err != nil { return nil, err } diff --git a/auth/iam_standalone.go b/auth/iam_standalone.go index 76407917..62245b82 100644 --- a/auth/iam_standalone.go +++ b/auth/iam_standalone.go @@ -18,10 +18,14 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" + "log" "net" "net/http" + "net/url" + "strconv" "time" "github.com/versity/versitygw/iamapi/private" @@ -39,6 +43,34 @@ const ( standaloneRequestTimeout = 10 * time.Second ) +// standaloneProbeWindow bounds how long NewIAMServiceStandalone waits for the +// IAM service to answer compatibly before it gives up, and +// standaloneProbeInterval how often it retries within that window. Both +// failures are retried, for different reasons: an unreachable service is the +// ordinary case of the two processes starting in parallel, and an +// incompatible one is what a gateway sees while the IAM service it is paired +// with is still rolling. Failing immediately on either would turn a routine +// deployment into a crash loop whose backoff long outlives the condition. +// +// Variables rather than constants so tests can shorten the window; nothing +// else writes them. +var ( + standaloneProbeWindow = 30 * time.Second + standaloneProbeInterval = 2 * time.Second +) + +// protocolMismatchError reports that whatever answered the private endpoints +// is not a standalone IAM service this gateway can use. It is a distinct type +// so the startup probe can tell an incompatible peer, which is fatal, from an +// unreachable one, which is not. +type protocolMismatchError struct { + detail string +} + +func (e *protocolMismatchError) Error() string { + return "iam standalone: " + e.detail +} + // IAMServiceStandaloneConfig configures IAMServiceStandalone. type IAMServiceStandaloneConfig struct { // Endpoint is either a "host:port" TCP address (mTLS required - @@ -46,8 +78,8 @@ type IAMServiceStandaloneConfig struct { // the standalone IAM service's own --private-ports address shape. Endpoint string // Access/Secret are this client's own SigV4 identity — the credential - // it signs its private requests with. Defaults both to the - // gateway's root account when unset. + // it signs its private requests with. Both must be set together, or + // both left empty to sign with the gateway's root account. Access string Secret string // ClientCert/ClientCertKey/ServerCA configure outbound mTLS. Required @@ -96,13 +128,13 @@ func NewIAMServiceStandalone(rootAcc Account, cfg IAMServiceStandaloneConfig) (* return nil, fmt.Errorf("iam standalone: endpoint is required") } - access := cfg.Access - if access == "" { - access = rootAcc.Access + if (cfg.Access == "") != (cfg.Secret == "") { + return nil, fmt.Errorf("iam standalone: access and secret must both be set, or both left empty to sign with the root account") } - secret := cfg.Secret - if secret == "" { - secret = rootAcc.Secret + + access, secret := cfg.Access, cfg.Secret + if access == "" { + access, secret = rootAcc.Access, rootAcc.Secret } client, baseURL, err := newStandaloneHTTPClient(cfg) @@ -110,14 +142,93 @@ func NewIAMServiceStandalone(rootAcc Account, cfg IAMServiceStandaloneConfig) (* return nil, err } - return &IAMServiceStandalone{ + svc := &IAMServiceStandalone{ client: client, baseURL: baseURL, access: access, secret: secret, rootAcc: rootAcc, cfg: cfg, - }, nil + } + + if err := svc.probeProtocol(); err != nil { + return nil, err + } + + return svc, nil +} + +// probeProtocol verifies at startup what every request verifies anyway, so a +// version skew is diagnosed once, here, instead of once per S3 request as an +// opaque 500. Because it is a signed request to a root-authenticated +// endpoint, reaching a compatible answer also proves the transport, the mTLS +// material, and this gateway's own IAM credential. +// +// An incompatible service is fatal: a gateway that cannot authorize a single +// request is more useful refusing to start, with the reason in its log, than +// running and serving errors. An unreachable one is only a warning — the two +// processes legitimately start in parallel, and every request checks the +// version regardless. +func (s *IAMServiceStandalone) probeProtocol() error { + deadline := time.Now().Add(standaloneProbeWindow) + + for { + var resp private.VersionResponse + // The endpoint takes no arguments; an empty object is the request. + err := s.doPrivateRequest(private.VersionPath, struct{}{}, &resp) + + // The version endpoint answers even a gateway the service will not + // serve — that is the whole point of exempting it from the service's + // own check — so the probe has to draw that conclusion itself from + // the minimum the service reports. Without this the one direction a + // gateway cannot detect from a response header would pass startup and + // fail on every request afterwards. + if err == nil && private.ProtocolVersion < resp.MinClient { + err = &protocolMismatchError{fmt.Sprintf( + "IAM service at %q serves private protocol %d and newer, this gateway speaks %d: upgrade the gateway", + s.cfg.Endpoint, resp.MinClient, private.ProtocolVersion)} + } + + if err == nil { + serverVersion := resp.ServerVersion + if serverVersion == "" { + serverVersion = "unknown" + } + fmt.Printf("standalone IAM service %q: version %s, private protocol %d\n", + s.cfg.Endpoint, serverVersion, resp.Protocol) + return nil + } + + if probeRetryable(err) && time.Now().Before(deadline) { + time.Sleep(standaloneProbeInterval) + continue + } + + var mismatch *protocolMismatchError + if errors.As(err, &mismatch) { + return fmt.Errorf("%w (still incompatible after %v, so this is a version skew rather than a rollout in progress)", + err, standaloneProbeWindow) + } + + log.Printf("WARNING: iam standalone: could not verify the IAM service at %q: %v; "+ + "the private protocol version is still checked on every request", + s.cfg.Endpoint, err) + return nil + } +} + +// probeRetryable reports whether a failed probe could resolve on its own. +// Only two can: the service not being up yet, and it being mid-rollout at an +// incompatible version. Anything it answered definitively — a rejected +// gateway credential above all — will answer the same way in thirty seconds, +// so retrying only delays the warning that says so. +func probeRetryable(err error) bool { + var mismatch *protocolMismatchError + if errors.As(err, &mismatch) { + return true + } + var transport *url.Error + return errors.As(err, &transport) } func newStandaloneHTTPClient(cfg IAMServiceStandaloneConfig) (*http.Client, string, error) { @@ -180,6 +291,11 @@ func (s *IAMServiceStandalone) doPrivateRequest(path string, reqBody, respBody a return fmt.Errorf("iam standalone: build request: %w", err) } req.Header.Set("Content-Type", "application/json") + // Set before signing, so it is covered by the signature: SigningInput + // FromRequest leaves SignedHeaders nil, and sigv4auth's default policy + // excludes only Authorization, User-Agent, X-Amzn-Trace-Id, Expect and + // Transfer-Encoding. + req.Header.Set(private.ProtocolHeader, strconv.Itoa(private.ProtocolVersion)) payloadHash := sigv4auth.PayloadSHA256Hex(bodyBytes) req.Header.Set("X-Amz-Content-Sha256", payloadHash) @@ -203,6 +319,12 @@ func (s *IAMServiceStandalone) doPrivateRequest(path string, reqBody, respBody a } defer resp.Body.Close() + // Checked before the status and before the body: a peer whose protocol + // this gateway cannot read is not one whose response it should interpret. + if err := s.checkServerProtocol(path, resp); err != nil { + return err + } + respBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("iam standalone: read response from %s: %w", path, err) @@ -240,11 +362,47 @@ func standaloneResponseError(path string, status int, body []byte) error { return ErrNoSuchUser case private.CodeInvalidToken: return ErrInvalidSessionToken + case private.CodeProtocolMismatch: + // The other direction of the same check: this gateway is too old for + // the IAM service to serve safely, which only that service can know. + return &protocolMismatchError{fmt.Sprintf( + "IAM service refused this gateway's private protocol version %d at %s: %s", + private.ProtocolVersion, path, errBody.Error)} } return fmt.Errorf("iam standalone: %s returned %d: %s", path, status, string(body)) } +// checkServerProtocol verifies the private protocol version the IAM service +// declared on a response. +// +// A missing version fails just as hard as an incompatible one. No build of +// this protocol omits the header, so a response without one did not come from +// a compatible IAM service — it came from something else answering on that +// address, such as a proxy returning its own error page. The message says +// what was observed rather than naming a cause, since both look identical from here. +func (s *IAMServiceStandalone) checkServerProtocol(path string, resp *http.Response) error { + value := resp.Header.Get(private.ProtocolHeader) + if value == "" { + return &protocolMismatchError{fmt.Sprintf( + "no %s header on the %d response from %s at %q: not a versioned IAM service", + private.ProtocolHeader, resp.StatusCode, path, s.cfg.Endpoint)} + } + + server, err := private.ParseProtocolVersion(value) + if err != nil { + return &protocolMismatchError{fmt.Sprintf("response from %s at %q: %v", path, s.cfg.Endpoint, err)} + } + + if server < private.ProtocolVersion { + return &protocolMismatchError{fmt.Sprintf( + "IAM service at %q speaks private protocol %d, this gateway requires %d or newer: upgrade the IAM service before the gateway", + s.cfg.Endpoint, server, private.ProtocolVersion)} + } + + return nil +} + // DeriveSigningKey implements SigningKeyProvider. Root is special-cased // locally: its secret is already known to this process either way, so // there's no reason to round-trip it through the IAM service. diff --git a/auth/iam_standalone_test.go b/auth/iam_standalone_test.go index 8f055888..57167711 100644 --- a/auth/iam_standalone_test.go +++ b/auth/iam_standalone_test.go @@ -16,9 +16,12 @@ package auth import ( "context" "errors" + "fmt" "net" + "net/http" "os" "path/filepath" + "strconv" "testing" "time" @@ -354,3 +357,265 @@ func TestNewIAMServiceStandaloneRespectsExplicitCredentials(t *testing.T) { t.Errorf("secret = %q, want %q", client.secret, "CUSTOMSECRET") } } + +// TestNewIAMServiceStandalonePartialCredentialsRejected covers a half +// configured signing identity: pairing one supplied key with the other half +// of the root credential would silently sign with a mismatched identity, so +// it must fail at construction instead. +func TestNewIAMServiceStandalonePartialCredentialsRejected(t *testing.T) { + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, sock := standaloneTestServer(t) + + for _, tc := range []struct { + name string + access string + secret string + }{ + {name: "access only", access: "AKIDCUSTOM"}, + {name: "secret only", secret: "CUSTOMSECRET"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{ + Endpoint: sock, + Access: tc.access, + Secret: tc.secret, + }) + if err == nil { + t.Fatal("expected an error when only one of access/secret is configured") + } + }) + } +} + +// TestIAMServiceStandaloneRejectsIncompatibleService covers every response a +// peer can give that this gateway must not interpret: no protocol header at +// all (a pre-versioning build, or something else answering on the address), +// one it cannot read, and one older than the protocol this gateway speaks. +// None of them may yield a working client. +func TestIAMServiceStandaloneRejectsIncompatibleService(t *testing.T) { + shortenProbeWindow(t) + + for _, tc := range []struct { + name string + protocol string + }{ + {"no header", ""}, + {"unreadable", "one"}, + {"older service", strconv.Itoa(private.ProtocolVersion - 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + sock := serveFakePrivate(t, tc.protocol, http.StatusOK, `{"protocol":0}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err == nil { + t.Fatal("expected the gateway to refuse to start against an incompatible IAM service") + } + var mismatch *protocolMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("error = %v, want a protocolMismatchError", err) + } + }) + } +} + +// TestIAMServiceStandaloneAcceptsNewerService confirms the rule is +// "not older", not "equal": an IAM service upgraded ahead of its gateways is +// the supported deployment order, so it must keep serving them. +func TestIAMServiceStandaloneAcceptsNewerService(t *testing.T) { + shortenProbeWindow(t) + + newer := strconv.Itoa(private.ProtocolVersion + 1) + sock := serveFakePrivate(t, newer, http.StatusOK, + `{"protocol":`+newer+`,"minClient":1,"serverVersion":"v9.9.9"}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone against a newer IAM service: %v", err) + } + defer client.Shutdown() +} + +// TestIAMServiceStandaloneRefusedByNewerService is the other direction of the +// same check: an IAM service that has raised its minimum turns this gateway +// away, and the gateway must recognise that as a version problem rather than +// as a generic server error. +func TestIAMServiceStandaloneRefusedByNewerService(t *testing.T) { + shortenProbeWindow(t) + + sock := serveFakePrivate(t, strconv.Itoa(private.ProtocolVersion+1), http.StatusBadRequest, + `{"error":"gateway speaks private protocol 1, this IAM service requires 2 or newer","code":"`+private.CodeProtocolMismatch+`"}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err == nil { + t.Fatal("expected the gateway to refuse to start when the IAM service refuses it") + } + var mismatch *protocolMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("error = %v, want a protocolMismatchError", err) + } +} + +// TestIAMServiceStandaloneRefusedByServiceMinimum covers the one direction a +// response header cannot express. The version endpoint is exempt from the +// service's own client-version check, so it answers 200 even to a gateway the +// service will not serve; the gateway has to reach that conclusion from the +// minimum the endpoint reports, or it would start cleanly and then fail every +// real request. +func TestIAMServiceStandaloneRefusedByServiceMinimum(t *testing.T) { + shortenProbeWindow(t) + + current := strconv.Itoa(private.ProtocolVersion) + sock := serveFakePrivate(t, current, http.StatusOK, + `{"protocol":`+current+`,"minClient":`+strconv.Itoa(private.ProtocolVersion+1)+`}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + _, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err == nil { + t.Fatal("expected the gateway to refuse to start below the IAM service's minimum") + } + var mismatch *protocolMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("error = %v, want a protocolMismatchError", err) + } +} + +// TestIAMServiceStandaloneUnreachableIsNotFatal confirms an unreachable IAM +// service only warns. The two processes legitimately start in parallel, and +// every request checks the version anyway, so refusing to start here would +// invent an ordering dependency without buying any safety. +func TestIAMServiceStandaloneUnreachableIsNotFatal(t *testing.T) { + shortenProbeWindow(t) + + sockDir, err := os.MkdirTemp("", "vgw-priv") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{ + Endpoint: filepath.Join(sockDir, "nothing-here.sock"), + }) + if err != nil { + t.Fatalf("an unreachable IAM service must not be fatal, got: %v", err) + } + defer client.Shutdown() +} + +// TestIAMServiceStandaloneDoesNotRetryDefinitiveRejection confirms the probe's +// retry window applies only to failures that can resolve on their own. A +// rejected gateway credential is answered by a service that is up and +// compatible, so it must warn at once rather than hold startup for the full +// window. Deliberately run against the real, unshortened window. +func TestIAMServiceStandaloneDoesNotRetryDefinitiveRejection(t *testing.T) { + sock := serveFakePrivate(t, strconv.Itoa(private.ProtocolVersion), http.StatusForbidden, + `{"error":"The security token included in the request is invalid","code":"InvalidClientTokenId"}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + + start := time.Now() + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("a rejected credential must warn, not fail startup: %v", err) + } + defer client.Shutdown() + + if elapsed := time.Since(start); elapsed > standaloneProbeInterval { + t.Errorf("probe took %v; a definitive rejection must not be retried", elapsed) + } +} + +// TestIAMServiceStandaloneSendsProtocolHeader confirms the gateway advertises +// its own version, and that it does so inside the signature: the real server +// verifies the signature over that header, so an unsigned or absent one would +// fail before reaching a handler. +func TestIAMServiceStandaloneSendsProtocolHeader(t *testing.T) { + store, sock := standaloneTestServer(t) + createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + if _, err := client.GetUserAccount("AKIAALICE"); err != nil { + t.Fatalf("GetUserAccount: %v", err) + } +} + +// TestIAMServiceStandaloneShapeChecksSurviveMatchingProtocol confirms the +// version header did not replace the response-shape checks. A peer can declare +// a compatible version and still send a matrix that disagrees — a forgotten +// bump, a locally patched build — and that must still fail closed. +func TestIAMServiceStandaloneShapeChecksSurviveMatchingProtocol(t *testing.T) { + shortenProbeWindow(t) + + // Compatible on the wire version, but one action decision short of the two + // actions asked for below. + current := strconv.Itoa(private.ProtocolVersion) + sock := serveFakePrivate(t, current, http.StatusOK, + `{"protocol":`+current+`,"decisions":[["allow"]]}`) + + rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret} + client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock}) + if err != nil { + t.Fatalf("NewIAMServiceStandalone: %v", err) + } + defer client.Shutdown() + + _, err = client.EvaluatePolicy("AKIAALICE", "", []Action{GetObjectAction, PutObjectAction}, []string{"arn:aws:s3:::b/o"}, nil) + if err == nil { + t.Fatal("expected a short decision row to fail closed even at a matching protocol version") + } +} + +// shortenProbeWindow collapses the startup probe's retry window for tests that +// deliberately point the client at an incompatible or absent service, which +// would otherwise sit through the full production window. +func shortenProbeWindow(t *testing.T) { + t.Helper() + + window, interval := standaloneProbeWindow, standaloneProbeInterval + standaloneProbeWindow, standaloneProbeInterval = 0, time.Millisecond + t.Cleanup(func() { standaloneProbeWindow, standaloneProbeInterval = window, interval }) +} + +// serveFakePrivate serves a stand-in for the standalone IAM service on a unix +// socket, answering every request with the given protocol header (omitted when +// empty), status, and body. It exists because the cases worth testing — a +// build older or newer than this one, or one predating versioning altogether — +// cannot be produced by the real server, which only ever speaks its own +// version. +func serveFakePrivate(t *testing.T, protocol string, status int, body string) string { + t.Helper() + + sockDir, err := os.MkdirTemp("", "vgw-priv") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + sockPath := filepath.Join(sockDir, "p.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if protocol != "" { + w.Header().Set(private.ProtocolHeader, protocol) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, body) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + + return sockPath +} diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 970a820b..21750ad2 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -812,13 +812,13 @@ func initFlags() []cli.Flag { }, &cli.StringFlag{ Name: "iam-standalone-access", - Usage: "access key this gateway signs its own calls to the standalone IAM service with (defaults to --access/root)", + Usage: "access key this gateway signs its own calls to the standalone IAM service with (must be set together with --iam-standalone-secret; both default to --access/--secret root)", EnvVars: []string{"VGW_IAM_STANDALONE_ACCESS"}, Destination: &standaloneIAMAccess, }, &cli.StringFlag{ Name: "iam-standalone-secret", - Usage: "secret key this gateway signs its own calls to the standalone IAM service with (defaults to --secret/root)", + Usage: "secret key this gateway signs its own calls to the standalone IAM service with (must be set together with --iam-standalone-access; both default to --access/--secret root)", EnvVars: []string{"VGW_IAM_STANDALONE_SECRET"}, Destination: &standaloneIAMSecret, }, diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index 847a6b2a..b4100a8e 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -298,8 +298,8 @@ type Config struct { StandaloneIAMEndpoint string // StandaloneIAMAccess/StandaloneIAMSecret are this gateway's own // signing identity for its calls to the private endpoints — not a - // fetched account. Both default to RootUserAccess/RootUserSecret when - // unset. + // fetched account. Both must be set together, or both left empty to + // default to RootUserAccess/RootUserSecret. StandaloneIAMAccess string StandaloneIAMSecret string // StandaloneClientCert/ClientCertKey are this gateway's client diff --git a/embedgw/iam.go b/embedgw/iam.go index d8180ade..d94e05bb 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -155,28 +155,39 @@ type IAMConfig struct { DisableOIDCThumbprintAutoFetch bool } +// privateAPIServer is the standalone IAM service's private endpoint set +// together with everything RunIAMAPI needs to serve and maintain it: the +// TLS options ServeMultiPort will enforce, and the cert storage backing +// them so a SIGHUP can swap in a rotated certificate. +type privateAPIServer struct { + api *private.PrivateAPI + tlsOpts netutil.TLSOptions + certStorage *netutil.CertStorage +} + // newPrivateAPI builds the standalone IAM service's private endpoint set // and the TLS options ServeMultiPort will enforce (mTLS, or nothing at all // for a unix-socket-only deployment — see netutil.RequireSecureTransport). -func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*private.PrivateAPI, netutil.TLSOptions, error) { +func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*privateAPIServer, error) { allSet := cfg.PrivateCertFile != "" && cfg.PrivateKeyFile != "" && cfg.PrivateClientCAFile != "" noneSet := cfg.PrivateCertFile == "" && cfg.PrivateKeyFile == "" && cfg.PrivateClientCAFile == "" if !allSet && !noneSet { - return nil, netutil.TLSOptions{}, fmt.Errorf("--private-cert, --private-cert-key, and --private-client-ca must all be set together, or all left empty for a unix-socket-only private listener") + return nil, fmt.Errorf("--private-cert, --private-cert-key, and --private-client-ca must all be set together, or all left empty for a unix-socket-only private listener") } var tlsOpts netutil.TLSOptions + var certStorage *netutil.CertStorage if allSet { - cs := netutil.NewCertStorage() - if err := cs.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile); err != nil { - return nil, netutil.TLSOptions{}, fmt.Errorf("private listener: load certs: %w", err) + certStorage = netutil.NewCertStorage() + if err := certStorage.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile); err != nil { + return nil, fmt.Errorf("private listener: load certs: %w", err) } pool, err := netutil.LoadCACertPool(cfg.PrivateClientCAFile) if err != nil { - return nil, netutil.TLSOptions{}, fmt.Errorf("private listener: %w", err) + return nil, fmt.Errorf("private listener: %w", err) } tlsOpts = netutil.TLSOptions{ - GetCertificate: cs.GetCertificate, + GetCertificate: certStorage.GetCertificate, ClientCAs: pool, RequireClientCert: true, } @@ -186,23 +197,26 @@ func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*private.PrivateAPI, n if cfg.PrivateSocketPerm != "" { perm, err := strconv.ParseUint(cfg.PrivateSocketPerm, 8, 32) if err != nil { - return nil, netutil.TLSOptions{}, fmt.Errorf("invalid PrivateSocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.PrivateSocketPerm, err) + return nil, fmt.Errorf("invalid PrivateSocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.PrivateSocketPerm, err) } privOpts = append(privOpts, private.WithPrivateSocketPerm(os.FileMode(perm))) } if cfg.Quiet { privOpts = append(privOpts, private.WithPrivateQuiet()) } + if cfg.Version != "" { + privOpts = append(privOpts, private.WithPrivateServerVersion(cfg.Version)) + } p, err := private.New(store, iamapi.RootCredentials{ Access: cfg.RootUserAccess, Secret: cfg.RootUserSecret, }, privOpts...) if err != nil { - return nil, netutil.TLSOptions{}, fmt.Errorf("init private IAM API: %w", err) + return nil, fmt.Errorf("init private IAM API: %w", err) } - return p, tlsOpts, nil + return &privateAPIServer{api: p, tlsOpts: tlsOpts, certStorage: certStorage}, nil } var iamAPIRunning atomic.Bool @@ -310,10 +324,9 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { return fmt.Errorf("init IAM API server: %w", err) } - var privateAPI *private.PrivateAPI - var privateTLSOpts netutil.TLSOptions + var privateAPI *privateAPIServer if len(cfg.PrivatePorts) > 0 { - privateAPI, privateTLSOpts, err = newPrivateAPI(store, cfg) + privateAPI, err = newPrivateAPI(store, cfg) if err != nil { return err } @@ -330,7 +343,7 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if privateAPI != nil { go func() { - errCh <- privateAPI.ServeMultiPort(cfg.PrivatePorts, privateTLSOpts) + errCh <- privateAPI.api.ServeMultiPort(cfg.PrivatePorts, privateAPI.tlsOpts) }() } @@ -357,6 +370,18 @@ Loop: fmt.Printf("iam api cert reloaded (cert: %s, key: %s)\n", cfg.CertFile, cfg.KeyFile) } } + // the private listener has its own certificate, so it needs + // its own reload: without this, new gateway-to-IAM TLS + // connections would keep getting the pre-rotation cert until + // the IAM service restarts. + if privateAPI != nil && privateAPI.certStorage != nil { + reloadErr := privateAPI.certStorage.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("private iam api cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("private iam api cert reloaded (cert: %s, key: %s)\n", cfg.PrivateCertFile, cfg.PrivateKeyFile) + } + } } } saveErr := err @@ -365,7 +390,7 @@ Loop: fmt.Fprintf(os.Stderr, "shutdown IAM API server: %v\n", err) } if privateAPI != nil { - if err := privateAPI.Shutdown(); err != nil { + if err := privateAPI.api.Shutdown(); err != nil { fmt.Fprintf(os.Stderr, "shutdown private IAM API server: %v\n", err) } } diff --git a/iamapi/private/errors.go b/iamapi/private/errors.go index 1cceb2c8..97932675 100644 --- a/iamapi/private/errors.go +++ b/iamapi/private/errors.go @@ -30,9 +30,10 @@ import ( // credential was rotated would tell the *user* their access key doesn't // exist. const ( - CodeNoSuchIdentity = "NoSuchIdentity" - CodeInvalidToken = "InvalidToken" - CodeBadRequest = "BadRequest" + CodeNoSuchIdentity = "NoSuchIdentity" + CodeInvalidToken = "InvalidToken" + CodeBadRequest = "BadRequest" + CodeProtocolMismatch = "ProtocolMismatch" ) // privateAPIError is a minimal local error for failures (like a malformed @@ -68,6 +69,18 @@ var ( } ) +// errProtocolMismatch reports that the calling gateway speaks a private +// protocol this build will not serve. Unlike the sentinels above it carries +// a message built at the call site, since which versions disagreed is the +// whole diagnosis. +func errProtocolMismatch(message string) *privateAPIError { + return &privateAPIError{ + status: http.StatusBadRequest, + code: CodeProtocolMismatch, + message: message, + } +} + // mapResolveError translates iamutil's identity-resolution sentinels into // the wire errors this protocol reports. Anything unrecognized falls through // unchanged and renders as a 500, which is the correct signal: it is a fault diff --git a/iamapi/private/handlers.go b/iamapi/private/handlers.go index adf3ecdc..d4273f5c 100644 --- a/iamapi/private/handlers.go +++ b/iamapi/private/handlers.go @@ -25,6 +25,20 @@ import ( "github.com/versity/versitygw/internal/sigv4auth" ) +// handleVersion reports what this build speaks. It is root-signed like every +// other endpoint here, which is what lets the gateway's startup probe verify +// its own credential and its mTLS transport in the same round trip that +// verifies the protocol — a rotated gateway credential is a far more common +// misconfiguration than a version skew, and an unauthenticated probe would +// report success right through one. +func (p *PrivateAPI) handleVersion(ctx fiber.Ctx) error { + return ctx.JSON(VersionResponse{ + Protocol: ProtocolVersion, + MinClient: MinClientProtocol, + ServerVersion: p.serverVersion, + }) +} + func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error { var req DeriveSigningKeyRequest if err := json.Unmarshal(ctx.Body(), &req); err != nil { diff --git a/iamapi/private/listener.go b/iamapi/private/listener.go index 5e56d559..437dd04c 100644 --- a/iamapi/private/listener.go +++ b/iamapi/private/listener.go @@ -19,6 +19,7 @@ import ( "time" "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/internal/netutil" ) @@ -53,6 +54,7 @@ func (p *PrivateAPI) ServeMultiPort(addrs []string, tlsOpts netutil.TLSOptions) ln, err = netutil.NewMultiAddrTLSListenerWithOptions(fiber.NetworkTCP, addr, tlsOpts, netutil.ListenerOptions{SocketPerm: p.socketPerm}) } if err != nil { + closeListeners(listeners) return fmt.Errorf("failed to bind private iam listener %s: %w", addr, err) } listeners = append(listeners, ln) @@ -62,6 +64,17 @@ func (p *PrivateAPI) ServeMultiPort(addrs []string, tlsOpts netutil.TLSOptions) return p.app.Listener(finalListener, fiber.ListenConfig{DisableStartupMessage: true}) } +// closeListeners closes already bound listeners so a failed bind part way +// through ServeMultiPort does not leave the earlier addresses (and unix +// socket files) held open. +func closeListeners(listeners []net.Listener) { + for _, ln := range listeners { + if err := ln.Close(); err != nil { + debuglogger.InternalError(fmt.Errorf("close private iam listener %v: %w", ln.Addr(), err)) + } + } +} + // Shutdown gracefully stops the private endpoint listeners. func (p *PrivateAPI) Shutdown() error { return p.app.ShutdownWithTimeout(shutDownDuration) diff --git a/iamapi/private/private_test.go b/iamapi/private/private_test.go index bf424251..96ce399a 100644 --- a/iamapi/private/private_test.go +++ b/iamapi/private/private_test.go @@ -20,6 +20,8 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" "time" @@ -115,6 +117,7 @@ func doPrivateRequest(t *testing.T, p *PrivateAPI, method, target, access, secre req := httptest.NewRequest(method, target, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set(ProtocolHeader, strconv.Itoa(ProtocolVersion)) req.ContentLength = int64(len(body)) hash := sigv4auth.PayloadSHA256Hex(body) @@ -127,6 +130,28 @@ func doPrivateRequest(t *testing.T, p *PrivateAPI, method, target, access, secre return resp } +// doPrivateRequestWithProtocol is doPrivateRequest with the protocol header +// set to an arbitrary value — including "" for a gateway build that predates +// versioning and sends none at all. +func doPrivateRequestWithProtocol(t *testing.T, p *PrivateAPI, target, protocol string, body []byte) *http.Response { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if protocol != "" { + req.Header.Set(ProtocolHeader, protocol) + } + req.ContentLength = int64(len(body)) + + signPrivateRequest(t, req, testRoot.Access, testRoot.Secret, sigv4auth.PayloadSHA256Hex(body)) + + resp, err := p.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + func readBody(t *testing.T, resp *http.Response) string { t.Helper() @@ -636,6 +661,157 @@ func TestPrivateAPIRejectsUnsignedRequest(t *testing.T) { } } +func TestPrivateAPIVersion(t *testing.T) { + store, err := storage.New(storage.Config{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + p, err := New(store, testRoot, WithPrivateServerVersion("v1.2.3")) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp := doPrivateRequest(t, p, http.MethodPost, VersionPath, testRoot.Access, testRoot.Secret, []byte("{}")) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, readBody(t, resp)) + } + + var got VersionResponse + if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Protocol != ProtocolVersion || got.MinClient != MinClientProtocol { + t.Errorf("VersionResponse = %+v, want protocol %d minClient %d", got, ProtocolVersion, MinClientProtocol) + } + if got.ServerVersion != "v1.2.3" { + t.Errorf("ServerVersion = %q, want %q", got.ServerVersion, "v1.2.3") + } +} + +// TestPrivateAPIVersionRequiresRootCredential confirms the version endpoint is +// authenticated like every other one here — that is what lets the gateway's +// startup probe verify its own credential in the same round trip. +func TestPrivateAPIVersionRequiresRootCredential(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + resp := doPrivateRequest(t, p, http.MethodPost, VersionPath, "AKIAALICE", "alicesecret", []byte("{}")) + if resp.StatusCode == http.StatusOK { + t.Fatalf("version endpoint served a non-root credential: %s", readBody(t, resp)) + } +} + +// TestPrivateAPIProtocolHeaderOnEveryResponse covers the success path, an +// application error, and an unknown route. The last two go through +// errorHandler, which must not drop the header — a mismatch response that +// carries no version is the one response an operator most needs it on. +func TestPrivateAPIProtocolHeaderOnEveryResponse(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + for _, tc := range []struct { + name string + path string + body string + }{ + {"success", ResolveIdentityPath, `{"accessKeyIds":["AKIAALICE"]}`}, + {"application error", DerivePath, "not json"}, + {"unknown route", "/private/nope", "{}"}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := doPrivateRequest(t, p, http.MethodPost, tc.path, testRoot.Access, testRoot.Secret, []byte(tc.body)) + if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) { + t.Errorf("%s = %q, want %q", ProtocolHeader, got, strconv.Itoa(ProtocolVersion)) + } + }) + } +} + +// TestPrivateAPIRejectsIncompatibleClientProtocol covers every request-header +// value this build refuses. A gateway too old to be served safely, and one +// whose version cannot be read at all, are both refused with a code the +// gateway dispatches on — never served on an assumed version. +func TestPrivateAPIRejectsIncompatibleClientProtocol(t *testing.T) { + p, store := newTestServer(t) + createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "") + + for _, tc := range []struct { + name string + protocol string + }{ + {"absent", ""}, + {"empty", " "}, + {"not a number", "one"}, + {"signed", "+1"}, + {"zero", "0"}, + {"absurdly long", "11111111111111111111"}, + } { + t.Run(tc.name, func(t *testing.T) { + resp := doPrivateRequestWithProtocol(t, p, ResolveIdentityPath, tc.protocol, []byte(`{"accessKeyIds":["AKIAALICE"]}`)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, readBody(t, resp)) + } + body := readBody(t, resp) + if !strings.Contains(body, CodeProtocolMismatch) { + t.Errorf("body = %s, want code %s", body, CodeProtocolMismatch) + } + if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) { + t.Errorf("%s = %q, want the refusing build's own version", ProtocolHeader, got) + } + }) + } +} + +// TestPrivateAPIVersionExemptFromClientProtocolCheck confirms the version +// endpoint answers a gateway this build would otherwise refuse. Without it, a +// future service that raised MinClientProtocol could not tell an older gateway +// why it was being turned away. +func TestPrivateAPIVersionExemptFromClientProtocolCheck(t *testing.T) { + p, _ := newTestServer(t) + + resp := doPrivateRequestWithProtocol(t, p, VersionPath, "", []byte("{}")) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, readBody(t, resp)) + } + if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) { + t.Errorf("%s = %q, want %q", ProtocolHeader, got, strconv.Itoa(ProtocolVersion)) + } +} + +func TestParseProtocolVersion(t *testing.T) { + for _, tc := range []struct { + value string + want int + }{ + {"1", 1}, + {"2", 2}, + {"1000", 1000}, + {"", 0}, + {" 1", 0}, + {"1 ", 0}, + {"+1", 0}, + {"-1", 0}, + {"0", 0}, + {"1.0", 0}, + {"v1", 0}, + {"99999", 0}, + } { + got, err := ParseProtocolVersion(tc.value) + if tc.want == 0 { + if err == nil { + t.Errorf("ParseProtocolVersion(%q) = %d, want an error", tc.value, got) + } + continue + } + if err != nil { + t.Errorf("ParseProtocolVersion(%q): %v", tc.value, err) + } + if got != tc.want { + t.Errorf("ParseProtocolVersion(%q) = %d, want %d", tc.value, got, tc.want) + } + } +} + // createTestRole creates a role with an optional inline permission policy // directly against store, the same way createTestUser bypasses the // control-plane API. Arn and RoleID are set explicitly because diff --git a/iamapi/private/server.go b/iamapi/private/server.go index 6b465007..b035c4a7 100644 --- a/iamapi/private/server.go +++ b/iamapi/private/server.go @@ -29,6 +29,7 @@ package private import ( "fmt" "os" + "strconv" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/logger" @@ -44,6 +45,39 @@ const ( DerivePath = "/private/derive-signing-key" EvaluatePath = "/private/evaluate-policy" ResolveIdentityPath = "/private/resolve-identity" + VersionPath = "/private/version" + + // ProtocolHeader carries the private protocol version each peer speaks. + // Both send it: the S3 gateway on every request, this service on every + // response, including error responses. + ProtocolHeader = "X-Vgw-Private-Protocol" + + // ProtocolVersion is the private protocol version this build speaks, and + // MinClientProtocol the oldest S3 gateway it will serve. Together they + // express compatibility in both directions, since a skew can be unsafe + // from either side: + // + // - Bump ProtocolVersion when the gateway starts relying on something + // an older service would silently ignore — a new request field, or a + // new endpoint. An unrecognized field is dropped by json.Unmarshal, + // so an older service evaluating without one (Condition being the + // worked example) is fail-open. The gateway catches this itself by + // refusing a service older than the version it speaks. + // + // - Bump both when this service starts returning something an older + // gateway must understand to stay fail-closed — a new deny dimension, + // or a decision matrix that narrows an Allow, as HasSessionPolicy/ + // SessionDecisions would have been had they landed later. An older + // gateway cannot detect this on its own: it does not know the field + // exists. This service refuses it instead. + // + // - Bump neither for an addition an older gateway can safely ignore. + // + // Changing an existing field's meaning in place is not a bump; it is a + // new route. The operational rule that falls out of all this: upgrade + // the IAM service before the gateways. + ProtocolVersion = 1 + MinClientProtocol = 1 // privateService is the SigV4 credential-scope service name the S3 // gateway signs its own requests to these endpoints with. It's an @@ -55,10 +89,11 @@ const ( // PrivateAPI is the standalone IAM service's private endpoint set type PrivateAPI struct { - app *fiber.App - store storage.Storer - socketPerm os.FileMode - quiet bool + app *fiber.App + store storage.Storer + socketPerm os.FileMode + quiet bool + serverVersion string } type PrivateAPIOption func(*PrivateAPI) @@ -77,6 +112,14 @@ func WithPrivateQuiet() PrivateAPIOption { return func(p *PrivateAPI) { p.quiet = true } } +// WithPrivateServerVersion sets the build version the version endpoint +// reports. It is what lets an operator map a protocol number back to an +// image, so it is worth passing even though nothing decides compatibility +// on it. +func WithPrivateServerVersion(version string) PrivateAPIOption { + return func(p *PrivateAPI) { p.serverVersion = version } +} + // New constructs the private endpoint set. root is the identity these // endpoints authenticate every request against. func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...PrivateAPIOption) (*PrivateAPI, error) { @@ -102,7 +145,10 @@ func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...Priva })) } + app.Use("*", p.checkProtocolVersion) + rootAuth := iammiddleware.VerifyRootOnlySigV4(privateService, &root) + app.Post(VersionPath, chainHandlers(rootAuth, p.handleVersion)) app.Post(DerivePath, chainHandlers(rootAuth, p.handleDeriveSigningKey)) app.Post(EvaluatePath, chainHandlers(rootAuth, p.handleEvaluatePolicy)) app.Post(ResolveIdentityPath, chainHandlers(rootAuth, p.handleResolveIdentity)) @@ -122,3 +168,68 @@ func chainHandlers(handlers ...fiber.Handler) fiber.Handler { return nil } } + +// checkProtocolVersion answers every response with this build's protocol +// version and refuses a gateway too old for it to serve safely. +// +// It runs before root authentication so a version refusal is reported and +// logged as exactly that, rather than as a misleading 403 about the +// gateway's credential. The only thing that discloses to an unauthenticated +// peer is the protocol version, which the response header carries either +// way, on a listener that is already mTLS- or unix-socket-only. +func (p *PrivateAPI) checkProtocolVersion(ctx fiber.Ctx) error { + ctx.Set(ProtocolHeader, strconv.Itoa(ProtocolVersion)) + + // The version endpoint answers even a gateway this build refuses to + // serve: it is how that gateway finds out what it is talking to. Keyed + // on the path rather than on registration order, since a route + // registered ahead of this middleware would not get the header set + // above either. + if ctx.Path() == VersionPath { + return ctx.Next() + } + + client, err := ParseProtocolVersion(ctx.Get(ProtocolHeader)) + if err != nil { + return errProtocolMismatch(err.Error()) + } + if client < MinClientProtocol { + return errProtocolMismatch(fmt.Sprintf( + "gateway speaks private protocol %d, this IAM service requires %d or newer: upgrade the gateway", + client, MinClientProtocol)) + } + + return ctx.Next() +} + +// ParseProtocolVersion reads a ProtocolHeader value. It is shared by both +// peers so they agree on what the header means, and is deliberately strict: +// a value it cannot read is a mismatch, never a default. Treating an absent +// or unreadable version as some assumed one is the fail-open direction for +// the check everything else is gated on. +func ParseProtocolVersion(value string) (int, error) { + if value == "" { + return 0, fmt.Errorf("no %s header", ProtocolHeader) + } + // Bounded before it is echoed into an error: this value is attacker- + // controlled and ends up in a log line. + if len(value) > maxProtocolDigits { + return 0, fmt.Errorf("malformed %s header", ProtocolHeader) + } + // Digits only. Atoi by itself would also accept a leading sign, which is + // not a version. + for i := 0; i < len(value); i++ { + if value[i] < '0' || value[i] > '9' { + return 0, fmt.Errorf("malformed %s header %q", ProtocolHeader, value) + } + } + version, err := strconv.Atoi(value) + if err != nil || version < 1 { + return 0, fmt.Errorf("malformed %s header %q", ProtocolHeader, value) + } + return version, nil +} + +// maxProtocolDigits bounds a protocol version's wire length, so an +// arbitrarily long header value never reaches a log line. +const maxProtocolDigits = 4 diff --git a/iamapi/private/types.go b/iamapi/private/types.go index 2b438ec5..4a1cf298 100644 --- a/iamapi/private/types.go +++ b/iamapi/private/types.go @@ -112,3 +112,17 @@ type EvaluatePolicyResponse struct { HasSessionPolicy bool `json:"hasSessionPolicy,omitempty"` PrincipalArn string `json:"principalArn,omitempty"` } + +// VersionResponse is the version endpoint's body. +// +// MinClient is load-bearing, and this is the only place it appears: the +// version endpoint is exempt from the service's own client-version check so +// that it can answer a gateway the service will not serve, which leaves the +// gateway to draw that conclusion itself from this field. Protocol duplicates +// the ProtocolHeader every response carries, and ServerVersion is the build +// tag — what maps a protocol number back to an image during a rollout. +type VersionResponse struct { + Protocol int `json:"protocol"` + MinClient int `json:"minClient"` + ServerVersion string `json:"serverVersion,omitempty"` +} diff --git a/iamapi/server.go b/iamapi/server.go index a2dabad0..c89c6c2f 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -188,6 +188,7 @@ func (s *IAMApiServer) ServeMultiPort(ports []string) error { ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm}) } if err != nil { + closeListeners(listeners) return fmt.Errorf("failed to bind iam listener %s: %w", portSpec, err) } @@ -213,6 +214,17 @@ func (s *IAMApiServer) ServeMultiPort(ports []string) error { }) } +// closeListeners closes already bound listeners so a failed bind part way +// through ServeMultiPort does not leave the earlier ports (and unix socket +// files) held open. +func closeListeners(listeners []net.Listener) { + for _, ln := range listeners { + if err := ln.Close(); err != nil { + debuglogger.InternalError(fmt.Errorf("close iam listener %v: %w", ln.Addr(), err)) + } + } +} + func (s *IAMApiServer) Shutdown() error { return s.app.ShutdownWithTimeout(shutDownDuration) } diff --git a/tests/integration/presigned_urls.go b/tests/integration/presigned_urls.go index 03791918..ae59a005 100644 --- a/tests/integration/presigned_urls.go +++ b/tests/integration/presigned_urls.go @@ -825,6 +825,8 @@ func PresignedAuth_Put_GetObject_with_data(s *S3Conf) error { return err } + req.Header = v4GetReq.SignedHeader + resp, err = s.httpClient.Do(req) if err != nil { return err @@ -892,6 +894,8 @@ func PresignedAuth_Put_GetObject_with_UTF8_chars(s *S3Conf) error { return err } + req.Header = v4GetReq.SignedHeader + resp, err = s.httpClient.Do(req) if err != nil { return err diff --git a/tests/integration/s3_iam_session_access_control.go b/tests/integration/s3_iam_session_access_control.go index 2e670229..1b2a5da6 100644 --- a/tests/integration/s3_iam_session_access_control.go +++ b/tests/integration/s3_iam_session_access_control.go @@ -17,6 +17,7 @@ package integration import ( "context" "fmt" + "net/http" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -497,7 +498,13 @@ func S3IAMSession_presigned_url_with_session_credentials(s *S3Conf) error { return fmt.Errorf("expected the presigned URL to carry X-Amz-Security-Token") } - resp, err := s.httpClient.Get(presigned.URL) + req, err := http.NewRequest(presigned.Method, presigned.URL, nil) + if err != nil { + return err + } + req.Header = presigned.SignedHeader + + resp, err := s.httpClient.Do(req) if err != nil { return err }