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/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 1f57429b..c487b23a 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -92,8 +92,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 929b7733..62a9307c 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -24,7 +24,7 @@ var ( func initEnv(dir string) { // both - debug = true + logLevel = "debug" region = "us-east-1" // server @@ -97,7 +97,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 0b5aa8a2..204a4d10 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -37,6 +37,8 @@ var ( iamServerVaultServerCert string iamServerVaultClientCert string iamServerVaultClientCertKey string + + iamServerDisableOIDCThumbprintAutoFetch bool ) func iamCommand() *cli.Command { @@ -137,6 +139,12 @@ func iamCommand() *cli.Command { Destination: &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"}, + Destination: &iamServerDisableOIDCThumbprintAutoFetch, + }, }, } } @@ -151,35 +159,41 @@ func runIAM(ctx *cli.Context) error { }() } + logLvl, err := parseLogLevel() + if err != nil { + return err + } + return embedgw.RunIAMAPI(ctx.Context, &embedgw.IAMConfig{ - RootUserAccess: rootUserAccess, - RootUserSecret: rootUserSecret, - Ports: ports, - MaxConnections: maxConnections, - MaxRequests: maxRequests, - CertFile: certFile, - KeyFile: keyFile, - Debug: debug, - Quiet: quiet, - KeepAlive: keepAlive, - HealthPath: healthPath, - SocketPerm: socketPerm, - IAMDir: iamServerDir, - VaultEndpointURL: iamServerVaultEndpointURL, - VaultNamespace: iamServerVaultNamespace, - VaultSecretStoragePath: iamServerVaultSecretStoragePath, - VaultSecretStorageNamespace: iamServerVaultSecretStorageNS, - VaultAuthMethod: iamServerVaultAuthMethod, - VaultAuthNamespace: iamServerVaultAuthNamespace, - VaultMountPath: iamServerVaultMountPath, - VaultRootToken: iamServerVaultRootToken, - VaultRoleID: iamServerVaultRoleID, - VaultRoleSecret: iamServerVaultRoleSecret, - VaultServerCert: iamServerVaultServerCert, - VaultClientCert: iamServerVaultClientCert, - VaultClientCertKey: iamServerVaultClientCertKey, - Version: Version, - Build: Build, - BuildTime: BuildTime, + RootUserAccess: rootUserAccess, + RootUserSecret: rootUserSecret, + Ports: ports, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + CertFile: certFile, + KeyFile: keyFile, + LogLevel: logLvl, + Quiet: quiet, + KeepAlive: keepAlive, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: iamServerDir, + VaultEndpointURL: iamServerVaultEndpointURL, + VaultNamespace: iamServerVaultNamespace, + VaultSecretStoragePath: iamServerVaultSecretStoragePath, + VaultSecretStorageNamespace: iamServerVaultSecretStorageNS, + VaultAuthMethod: iamServerVaultAuthMethod, + VaultAuthNamespace: iamServerVaultAuthNamespace, + VaultMountPath: iamServerVaultMountPath, + VaultRootToken: iamServerVaultRootToken, + VaultRoleID: iamServerVaultRoleID, + VaultRoleSecret: iamServerVaultRoleSecret, + VaultServerCert: iamServerVaultServerCert, + VaultClientCert: iamServerVaultClientCert, + VaultClientCertKey: iamServerVaultClientCertKey, + DisableOIDCThumbprintAutoFetch: iamServerDisableOIDCThumbprintAutoFetch, + Version: Version, + Build: Build, + BuildTime: BuildTime, }) } diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index f366fa69..c950873f 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -24,6 +24,7 @@ import ( "github.com/urfave/cli/v2" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/s3api/utils" ) @@ -49,6 +50,7 @@ var ( adminLogFile string healthPath string virtualDomain string + logLevel string debug bool keepAlive bool pprof string @@ -372,9 +374,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, @@ -813,6 +825,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. @@ -829,6 +854,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: rootUserAccess, RootUserSecret: rootUserSecret, @@ -845,7 +875,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 8cc7826e..f4996320 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", @@ -296,7 +297,7 @@ func initTestCommands() []*cli.Command { integration.WithPartSize(partSize), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if hostStyle { @@ -357,7 +358,7 @@ func initTestCommands() []*cli.Command { integration.WithConcurrency(concurrency), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if checksumDisable { @@ -404,7 +405,7 @@ func websiteHostingAction(ctx *cli.Context) error { if websitePortTest != "" { opts = append(opts, integration.WithWebsitePort(websitePortTest)) } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } @@ -414,7 +415,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()) } @@ -430,7 +431,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 { @@ -456,7 +457,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()) } @@ -480,7 +481,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/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 959217c7..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. @@ -120,6 +123,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,9 +208,10 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.Quiet { opts = append(opts, iamapi.WithQuiet()) } - if cfg.Debug { - debuglogger.SetDebugEnabled() + if cfg.DisableOIDCThumbprintAutoFetch { + opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) } + 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 8f2f6c1d..4d37eed9 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -363,9 +363,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 ca9de513..6c4cab89 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/versity/versitygw go 1.25.0 +toolchain go1.26.5 + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 @@ -13,11 +15,13 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.12 github.com/aws/aws-sdk-go-v2/service/iam v1.54.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 + github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 github.com/aws/smithy-go v1.27.3 github.com/cespare/xxhash/v2 v2.3.0 github.com/davecgh/go-spew v1.1.1 github.com/go-ldap/ldap/v3 v3.4.13 github.com/gofiber/fiber/v3 v3.3.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 +60,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/gofiber/schema v1.8.0 // indirect github.com/gofiber/utils/v2 v2.1.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 @@ -85,6 +87,6 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index b25b1fc8..f601402c 100644 --- a/go.sum +++ b/go.sum @@ -242,8 +242,8 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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 34511a3f..5b06eef1 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,32 +17,39 @@ package iamapi import ( "errors" "fmt" - "strconv" + "slices" "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" + "github.com/versity/versitygw/internal/httpctx" ) 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) { - 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 +102,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 } @@ -114,19 +117,26 @@ 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 !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 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 { + if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err } @@ -150,14 +160,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 +186,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 +199,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 } } @@ -233,3 +234,1072 @@ 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, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if 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, err := iamutil.GetUserName(ctx, "UpdateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if 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, err := iamutil.GetUserName(ctx, "DeleteAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if 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, err := iamutil.GetUserName(ctx, "ListAccessKeys", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListAccessKeys") + if err != nil { + return nil, err + } + + 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 +} + +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 +} + +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 +} + +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 +} + +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 +} + +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 88b59f45..2f12d765 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -14,21 +14,35 @@ package iamapi import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "encoding/xml" "net/http" + "net/http/httptest" "net/url" "regexp" + "slices" "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" + "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}$`) +var roleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) func TestIAMApiControllerUserLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -154,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) { @@ -478,6 +534,1526 @@ 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.") +} + +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 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 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() @@ -506,6 +2082,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() @@ -522,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 28f52871..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 @@ -52,14 +54,14 @@ const ( ErrInvalidRegion ErrMissingHostSignedHeader ErrInvalidClientTokenID - ErrInvalidContentLength ErrThrottling - - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys + ErrInvalidAccessKeyIDChars + ErrDeleteConflict + ErrDeleteConflictPolicies ) type APIError interface { @@ -113,7 +115,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{ @@ -123,7 +125,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 +137,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Rate exceeded.", HTTPStatusCode: http.StatusBadRequest, }, - ErrMissingAuthenticationToken: { Type: TypeSender, Code: "MissingAuthenticationToken", @@ -155,7 +155,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 +173,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Authorization header requires Credential, SignedHeaders, and Signature.", HTTPStatusCode: http.StatusBadRequest, }, - ErrSignatureDoesNotMatch: { Type: TypeSender, Code: "SignatureDoesNotMatch", @@ -211,13 +209,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", @@ -236,6 +227,24 @@ 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, + }, + ErrDeleteConflictPolicies: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete policies first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -246,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 @@ -334,6 +361,26 @@ 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 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) } @@ -362,6 +409,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)) } @@ -378,6 +437,160 @@ 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 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)) +} + +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) +} + +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) +} + +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 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 new file mode 100644 index 00000000..a60320db --- /dev/null +++ b/iamapi/internal/iamutil/access_key.go @@ -0,0 +1,128 @@ +// 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" + "strings" + + "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 + + // 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]+$`) + +// 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 +} + +// 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 { + 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/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..ab8dacc2 --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -0,0 +1,149 @@ +// 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 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 +// 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. +// +// 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 + + 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) + } + } + + thumbprint, err := dialAndVerifyThumbprint(ctx, net.JoinHostPort(ips[0].String(), "443"), host, nil) + if err != nil { + 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 "", errors.New("iamutil: non-TLS connection") + } + + return ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) +} + +// 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..66fc72fd --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -0,0 +1,168 @@ +// 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" + "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") + } +} + +// 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: 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/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/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 1922aa85..e1c2d981 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" @@ -40,12 +41,21 @@ const ( userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" maxTagKeyLen = 128 maxTagValLen = 256 + + roleIDPrefix = "AROA" + roleIDRandomLen = 17 + + MaxRoleDescriptionLen = 1000 + + DefaultMaxSessionDuration = 3600 + MinMaxSessionDuration = 3600 + MaxMaxSessionDuration = 43200 ) 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 +73,125 @@ 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"). +// 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 +} + +// 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"). +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 +235,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) } @@ -151,15 +282,40 @@ 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 +} + +// 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) { 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/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 new file mode 100644 index 00000000..31866eba --- /dev/null +++ b/iamapi/policy/document.go @@ -0,0 +1,196 @@ +// 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" + "fmt" +) + +// 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 + Condition 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 { + // 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 + } + 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 := unmarshalStrict(raw.Statement, &stmts); err == nil { + d.Statement = stmts + return nil + } + + var single Statement + 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 +// 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..9aaa9d69 --- /dev/null +++ b/iamapi/policy/document_test.go @@ -0,0 +1,157 @@ +// 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") + } + }) + + 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 new file mode 100644 index 00000000..9c09a737 --- /dev/null +++ b/iamapi/policy/trust.go @@ -0,0 +1,409 @@ +// 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" + "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" + +// 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 +// 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, 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": + 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 !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 + } + 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 + } + + 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)) + } + } + } + } + + 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)) + } + } + 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 new file mode 100644 index 00000000..de888ec2 --- /dev/null +++ b/iamapi/policy/trust_test.go @@ -0,0 +1,152 @@ +// 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" +) + +// 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 { + 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}, + {"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}, + + {"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}, + // 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 { + 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/policy/validate.go b/iamapi/policy/validate.go new file mode 100644 index 00000000..3987897b --- /dev/null +++ b/iamapi/policy/validate.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 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, 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": + default: + return errSyntax + } + + if len(s.Principal) > 0 || len(s.NotPrincipal) > 0 { + return errPrincipalNotAllowed + } + + if !conditionShapeValid(s.Condition) { + return errSyntax + } + + 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..3175d2e6 --- /dev/null +++ b/iamapi/policy/validate_test.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 policy + +import ( + "errors" + "strings" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +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}, + + {"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}, + {"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/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 823cba05..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 { @@ -38,23 +50,79 @@ 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{ - "CreateUser": ctrl.CreateUser, - "DeleteUser": ctrl.DeleteUser, - "GetUser": ctrl.GetUser, - "ListUsers": ctrl.ListUsers, - "UpdateUser": ctrl.UpdateUser, + // User CRUD + "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": r.Ctrl.CreateAccessKey, + "UpdateAccessKey": r.Ctrl.UpdateAccessKey, + "DeleteAccessKey": r.Ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": r.Ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": r.Ctrl.ListAccessKeys, + // User Inline Policy CRUD + "PutUserPolicy": r.Ctrl.PutUserPolicy, + "GetUserPolicy": r.Ctrl.GetUserPolicy, + "DeleteUserPolicy": r.Ctrl.DeleteUserPolicy, + "ListUserPolicies": r.Ctrl.ListUserPolicies, + // Role CRUD + "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": 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, + // 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) @@ -66,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 c5ccbe47..43d09a2c 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, @@ -98,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, + }, })) } @@ -161,6 +168,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 be4e0f09..27acfc3a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -21,8 +21,10 @@ import ( "sort" "strings" "sync" + "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" ) @@ -50,16 +52,100 @@ 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"` + // 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"` + + // 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"` + + // 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 { - return iamConfig{Users: map[string]types.User{}} + return iamConfig{ + Users: map[string]types.User{}, + AccessKeyIndex: map[string]string{}, + UserNameIndex: map[string]string{}, + Roles: map[string]types.Role{}, + RoleNameIndex: map[string]string{}, + OIDCProviders: map[string]types.OIDCProvider{}, + Sessions: map[string]types.Session{}, + } } 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) + } + 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 + } + } + + 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, +// 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) { @@ -72,7 +158,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 { @@ -82,6 +169,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) @@ -100,11 +188,19 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - if _, ok := conf.Users[username]; !ok { + canonical, user, ok := lookupUser(conf, username) + 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) + } - delete(conf.Users, username) + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) return json.Marshal(conf) }) return unwrapAPIError(err) @@ -119,7 +215,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) } @@ -127,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() @@ -187,7 +303,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) } @@ -196,8 +312,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) } } @@ -212,10 +328,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) @@ -226,8 +347,964 @@ 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 + } + + canonical, user, ok := lookupUser(conf, 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[canonical] = user + conf.AccessKeyIndex[input.AccessKeyID] = canonical + + created = types.AccessKey{ + UserName: canonical, + 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 + } + + canonical, user, ok := lookupUser(conf, 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[canonical] = 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 + } + + canonical, user, ok := lookupUser(conf, 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[canonical] = 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) 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() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + canonical, user, ok := lookupUser(conf, 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: canonical, + 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 (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 + } + + canonical, user, ok := lookupUser(conf, 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[canonical] = 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 := lookupUser(conf, 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 + } + + canonical, user, ok := lookupUser(conf, 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[canonical] = 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 := lookupUser(conf, 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 (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 (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) + cloned.AccessKeys = slices.Clone(user.AccessKeys) + 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 +} + +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 (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) + 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 c2544815..28aa5a8d 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -19,13 +19,53 @@ 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 + +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// 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 + +// 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 + +// 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") + 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 { @@ -47,13 +87,144 @@ 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 +} + +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 +} + +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 +} + +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 +} + +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) 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) + + 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) + // 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) + 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) + + 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) + + // 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 + + 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 6947cefa..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), }, @@ -198,6 +199,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) } @@ -205,3 +222,459 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err) } } + +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()) + 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.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) + } + + 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: "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"}]}`, + MaxSessionDuration: 3600, + Tags: []types.Tag{ + {Key: "env", Value: "test"}, + }, + }, + { + Path: "/engineering/platform/", + RoleName: "bob-role", + 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"}]}`, + MaxSessionDuration: 3600, + }, + { + Path: "/ops/", + RoleName: "carol-role", + 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"}]}`, + 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: "AROAx5555555555555555"}); !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) + } +} + +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: "AROAx2222222222222222", + 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) + } +} + +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 de97762b..db356bbf 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -16,22 +16,80 @@ package storage import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" "net/http" + "slices" "sort" "strings" "time" 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" ) 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 @@ -190,15 +248,41 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return 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) { + 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, }, @@ -227,41 +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 { - if _, err := s.GetUser(ctx, username); 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)) } - return s.deleteByPath(username) + return iamerr.ConcurrentModification() } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - path := s.secretStoragePath + "/" + username + user, _, err := s.readUserVersion(username) + return user, err +} + +// 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, username) - 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 @@ -273,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 @@ -328,17 +491,18 @@ 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 } + 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 @@ -358,31 +522,458 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != input.UserName { - // 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(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 { + if err := s.deleteOldUserAfterRename(originalName); err != nil { return nil, err } + } else if _, err := s.replaceUser(ctx, *user, version); err != nil { + return nil, err } return cloneUser(*user), nil } +// 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 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) { + 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, + }) + 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 &created, nil +} + +func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error { + _, 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 + } + } + return iamerr.NoSuchEntityAccessKey(input.AccessKeyID) + }) + return err +} + +func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error { + _, 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) + return nil + }) + return err +} + +func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, 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 + } + } + + 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: user.UserName, + LastUsedDate: key.LastUsedDate, + ServiceName: key.LastUsedService, + Region: key.LastUsedRegion, + }, nil + } + } + } + + 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 { + 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 +} + +func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { + _, 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) + } + + 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 +} + +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 { + _, 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) + return nil + }) + 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 { - 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 { @@ -396,6 +987,958 @@ func (s *VaultStore) deleteByPath(username string) error { return nil } +// rolesPath is the KV prefix under which roles are stored, kept distinct +// 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" +} + +func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + key := caseFoldKey(role.RoleName) + + role.EnsureRoleLastUsed() + + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) + } + + path := s.rolesPath() + "/" + key + req := schema.KvV2WriteRequest{ + Data: map[string]any{key: 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) { + role, _, err := s.readRoleVersion(roleName) + return role, err +} + +// 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, 0, iamerr.NoSuchEntityRole(roleName) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + 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, 0, iamerr.NoSuchEntityRole(roleName) + } + return nil, 0, err + } + } + + role, err := parseVaultRole(resp.Data.Data, key) + if err != nil { + return nil, 0, err + } + return cloneRole(role), kvVersion(resp.Data.Metadata), 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 +} + +// 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 { + 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) + } + return iamerr.ConcurrentModification() +} + +func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + 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 { + _, 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) + } + + 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 +} + +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 { + _, 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) + return nil + }) + 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 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) + } + + 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() + "/" + caseFoldKey(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 +} + +// 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) { + 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, 0, 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, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + 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, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return nil, 0, err + } + } + + provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) + if err != nil { + return nil, 0, err + } + return cloneOIDCProvider(provider), kvVersion(resp.Data.Metadata), 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 +} + +func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { + 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 + }) +} + +func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { + 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 + }) +} + +func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + provider.ThumbprintList = thumbprints + return nil + }) +} + +// 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) + } + 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") + +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 +} + +// 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/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/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/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/iamapi/types/policy.go b/iamapi/types/policy.go new file mode 100644 index 00000000..863084ec --- /dev/null +++ b/iamapi/types/policy.go @@ -0,0 +1,146 @@ +// 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"` +} + +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/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/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/iamapi/types/user.go b/iamapi/types/user.go index 40fdc2a8..f8f4a91f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -99,12 +99,14 @@ 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:"-"` + Policies Policies `xml:"-"` } type Tag struct { 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/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/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 efce34e5..fa798049 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -131,6 +131,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 3fde9d1a..1c73dd50 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1121,6 +1121,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) @@ -1159,6 +1160,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) } @@ -1174,6 +1176,377 @@ 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 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 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_has_policies) + 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 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 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 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) + ts.Run(IAMAssumeRoleWithWebIdentity_github_oidc_live) +} + +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) @@ -1182,6 +1555,34 @@ func TestIAM(ts *TestState) { TestIAMListUsers(ts) TestIAMDeleteUser(ts) TestIAMUpdateUser(ts) + TestIAMCreateAccessKey(ts) + TestIAMUpdateAccessKey(ts) + TestIAMDeleteAccessKey(ts) + TestIAMGetAccessKeyLastUsed(ts) + TestIAMListAccessKeys(ts) + TestIAMPutUserPolicy(ts) + TestIAMGetUserPolicy(ts) + TestIAMDeleteUserPolicy(ts) + TestIAMListUserPolicies(ts) + TestIAMCreateRole(ts) + TestIAMGetRole(ts) + TestIAMListRoles(ts) + TestIAMDeleteRole(ts) + TestIAMUpdateAssumeRolePolicy(ts) + TestIAMPutRolePolicy(ts) + TestIAMGetRolePolicy(ts) + TestIAMDeleteRolePolicy(ts) + TestIAMListRolePolicies(ts) + TestIAMCreateOpenIDConnectProvider(ts) + TestIAMGetOpenIDConnectProvider(ts) + TestIAMListOpenIDConnectProviders(ts) + TestIAMDeleteOpenIDConnectProvider(ts) + TestIAMAddClientIDToOpenIDConnectProvider(ts) + TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) + TestIAMUpdateOpenIDConnectProviderThumbprint(ts) + TestIAMAssumeRoleWithWebIdentity(ts) + TestIAMGetCallerIdentity(ts) + TestIAMAccessControl(ts) } func TestAccessControl(ts *TestState) { @@ -1463,911 +1864,1200 @@ 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_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, - "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_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, - "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, - "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_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, - "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_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_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, + "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, + "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_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, + "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, + "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_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, + "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_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_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, } } 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_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_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_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/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_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_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 12a0a62e..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 { @@ -228,6 +249,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_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_delete_role.go b/tests/integration/iam_delete_role.go new file mode 100644 index 00000000..a0e5c57a --- /dev/null +++ b/tests/integration/iam_delete_role.go @@ -0,0 +1,128 @@ +// 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_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 { + 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_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_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_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_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_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/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_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_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_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_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_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_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_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_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_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) +} 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) +} 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_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) +} 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/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 +} 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...) +} 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()) } 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