mirror of
https://github.com/versity/versitygw.git
synced 2026-08-21 22:56:28 +00:00
Merge pull request #2230 from versity/sis/iam-access-key-crud
feat: add IAM user access key management
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
+43
-29
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+32
-2
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+41
-25
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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. `<?xml version="1.0" encoding="UTF-8"?>`) 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("<?xml %s?>", 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()
|
||||
}
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><AssumeRoleWithWebIdentityResult><AssumedRoleUser><AssumedRoleId>AROAEXAMPLE:session</AssumedRoleId><Arn>arn:aws:sts::123456789012:assumed-role/role/session</Arn></AssumedRoleUser><Provider>https://idp.example.com</Provider><Credentials><AccessKeyId>ASIAabcdefghijklmnop</AccessKeyId><SecretAccessKey>supersecretvalue1234567890</SecretAccessKey><SessionToken>tokentokentokentoken</SessionToken><Expiration>2026-07-30T12:00:00Z</Expiration></Credentials><SubjectFromWebIdentityToken>subject-123</SubjectFromWebIdentityToken></AssumeRoleWithWebIdentityResult><ResponseMetadata><RequestId>req-123</RequestId></ResponseMetadata></AssumeRoleWithWebIdentityResponse>`
|
||||
|
||||
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, "<SecretAccessKey>****</SecretAccessKey>") {
|
||||
t.Errorf("expected SecretAccessKey to be fully masked:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<SessionToken>****</SessionToken>") {
|
||||
t.Errorf("expected SessionToken to be fully masked:\n%s", got)
|
||||
}
|
||||
// AccessKeyId is partially masked: first 4 chars visible.
|
||||
if !strings.Contains(got, "<AccessKeyId>ASIA****</AccessKeyId>") {
|
||||
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/"`,
|
||||
"<AssumedRoleId>AROAEXAMPLE:session</AssumedRoleId>",
|
||||
"<Arn>arn:aws:sts::123456789012:assumed-role/role/session</Arn>",
|
||||
"<Provider>https://idp.example.com</Provider>",
|
||||
"<Expiration>2026-07-30T12:00:00Z</Expiration>",
|
||||
"<RequestId>req-123</RequestId>",
|
||||
} {
|
||||
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 := `<Root xmlns="urn:example"><Outer id="1"><Inner>value</Inner><Inner>value2</Inner></Outer></Root>`
|
||||
out, ok := maskXMLBody([]byte(body))
|
||||
if !ok {
|
||||
t.Fatalf("maskXMLBody: expected ok=true")
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
if strings.Count(got, "<Inner>") != 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{
|
||||
"",
|
||||
" ",
|
||||
"<Unclosed>",
|
||||
`{"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -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 != "" {
|
||||
|
||||
+15
-4
@@ -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 {
|
||||
|
||||
+22
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<key> 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/<key> (and, identically, the generic aws:ResourceTag/<key>)
|
||||
// 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/<key> form is populated identically to the
|
||||
// iam:ResourceTag/<key> 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/<key> 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))
|
||||
}
|
||||
}
|
||||
+1112
-42
File diff suppressed because it is too large
Load Diff
+2563
-15
File diff suppressed because it is too large
Load Diff
+228
-15
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:<GlobalKey>"-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 "<RoleID>:<RoleSessionName>"
|
||||
// form). For the three actions that accept a Tags parameter at creation
|
||||
// time, aws:RequestTag/<key> (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/<key>
|
||||
// (IAM's own documented resource-tag key) and aws:ResourceTag/<key> (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/<key> 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/<key> 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/<key> 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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "<paramName>.member.1",
|
||||
// "<paramName>.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::<account>:<resource-type>/<resource>) 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+169
-13
@@ -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()])
|
||||
|
||||
@@ -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::<account>:role/<path/><name>, 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 <provider>:aud trust-policy condition key)
|
||||
// along with its original aud claim value(s) (mapped to <provider>: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: <iat|sub>." — 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "<provider-url>:<claim>" keyed context for trust-policy
|
||||
// evaluation, or an "aws:<GlobalKey>" 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 "<service>:<Action>" 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:<GlobalKey>"-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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// "<url>:<claim>" 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::<account>:oidc-provider/<url>"
|
||||
// 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::<account>:oidc-provider/<url>"
|
||||
// (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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: "<provider-url>:<claim-name>".
|
||||
type WebIdentityContext struct {
|
||||
ProviderURL string
|
||||
// Audience is the token's effective audience: azp when present,
|
||||
// otherwise the token's single aud value. Mapped to <provider-url>: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 <provider-url>: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: "<provider-url>:<claim>" 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+87
-14
@@ -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("<UnknownOperationException/>\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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1087
-10
File diff suppressed because it is too large
Load Diff
+172
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1574
-31
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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::<account>:oidc-provider/<url> 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+1596
-906
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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/<name> — not arn:...:role/some/path/<name>. 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 ("<provider-url>:<claim>") 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
|
||||
}
|
||||
@@ -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:<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": "<jwt>", "count": <n>}. 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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user