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(">")
+ out.WriteString(n.name)
+ 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("")
+ out.WriteString(n.name)
+ 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