From 001e7d88e4c3a213f0e706f66cf40da972d526b9 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 6 Jul 2026 22:59:24 +0400 Subject: [PATCH 1/7] feat: add IAM user access key management Add `CreateAccessKey`, `UpdateAccessKey`, `DeleteAccessKey`, `ListAccessKeys`, and `GetAccessKeyLastUsed` actions for managing user access keys and retrieving their latest usage details. Generate AWS-style access key IDs and secrets, validate key identifiers and statuses, enforce per-user key quotas, and prevent deleting users that still own access keys. Persist access keys across internal and Vault storage backends with ownership indexing, pagination, and IAM-compatible errors and XML responses. --- iamapi/controller.go | 197 +++++++++++ iamapi/iamerr/errors.go | 41 ++- iamapi/internal/iamutil/access_key.go | 88 +++++ iamapi/internal/iamutil/user.go | 18 +- iamapi/router.go | 7 + iamapi/storage/internal.go | 224 +++++++++++- iamapi/storage/storer.go | 47 ++- iamapi/storage/storer_test.go | 16 + iamapi/storage/vault.go | 188 +++++++++- iamapi/types/access_key.go | 121 +++++++ iamapi/types/user.go | 13 +- tests/integration/group-tests.go | 108 ++++++ tests/integration/iam_create_access_key.go | 151 ++++++++ tests/integration/iam_create_user.go | 17 + tests/integration/iam_delete_access_key.go | 169 +++++++++ tests/integration/iam_delete_user.go | 29 ++ .../iam_get_access_key_last_used.go | 137 ++++++++ tests/integration/iam_list_access_keys.go | 331 ++++++++++++++++++ tests/integration/iam_update_access_key.go | 254 ++++++++++++++ 19 files changed, 2129 insertions(+), 27 deletions(-) create mode 100644 iamapi/internal/iamutil/access_key.go create mode 100644 iamapi/types/access_key.go create mode 100644 tests/integration/iam_create_access_key.go create mode 100644 tests/integration/iam_delete_access_key.go create mode 100644 tests/integration/iam_get_access_key_last_used.go create mode 100644 tests/integration/iam_list_access_keys.go create mode 100644 tests/integration/iam_update_access_key.go diff --git a/iamapi/controller.go b/iamapi/controller.go index 34511a3f..9718836e 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -233,3 +233,200 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { Result: types.UpdateUserResult{User: updated}, }}, nil } + +func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required CreateAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + for range 3 { + accessKeyID, err := iamutil.GenerateAccessKeyID() + if err != nil { + return nil, err + } + secretAccessKey, err := iamutil.GenerateSecretAccessKey() + if err != nil { + return nil, err + } + + stored, err := c.store.CreateAccessKey(ctx.Context(), storage.CreateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + Status: iamutil.AccessKeyStatusActive, + CreateDate: time.Now().UTC().Truncate(time.Second), + }) + if errors.Is(err, storage.ErrAccessKeyIDAlreadyExists) { + debuglogger.Logf("IAM access key id collision for user %q: %v", userName, err) + continue + } + if err != nil { + debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) + return nil, err + } + + return &Response{ + Data: &types.CreateAccessKeyResponse{ + Result: types.CreateAccessKeyResult{AccessKey: *stored}, + }, + }, nil + } + + err := fmt.Errorf("generate IAM access key id: exhausted collision retries") + debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) + return nil, err +} + +func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + status, ok := iamutil.RequestParam(ctx, "Status") + if !ok || status == "" { + debuglogger.Logf("missing required UpdateAccessKey parameter: Status") + return nil, iamerr.MissingParameter("Status") + } + if err := iamutil.ValidateAccessKeyStatus(status); err != nil { + return nil, err + } + + if err := c.store.UpdateAccessKey(ctx.Context(), storage.UpdateAccessKeyInput{ + UserName: userName, + AccessKeyID: accessKeyID, + Status: status, + }); err != nil { + debuglogger.Logf("failed to update IAM access key %q for user %q: %v", accessKeyID, userName, err) + return nil, err + } + + return &Response{Data: &types.UpdateAccessKeyResponse{}}, nil +} + +func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required DeleteAccessKey parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required DeleteAccessKey parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + if err := c.store.DeleteAccessKey(ctx.Context(), userName, accessKeyID); err != nil { + debuglogger.Logf("failed to delete IAM access key %q for user %q: %v", accessKeyID, userName, err) + return nil, err + } + + return &Response{Data: &types.DeleteAccessKeyResponse{}}, nil +} + +func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error) { + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + debuglogger.Logf("missing required GetAccessKeyLastUsed parameter: AccessKeyId") + return nil, iamerr.MissingParameter("AccessKeyId") + } + if err := iamutil.ValidateAccessKeyID(accessKeyID); err != nil { + return nil, err + } + + out, err := c.store.GetAccessKeyLastUsed(ctx.Context(), accessKeyID) + if err != nil { + debuglogger.Logf("failed to get IAM access key last used %q: %v", accessKeyID, err) + return nil, err + } + + serviceName := out.ServiceName + if serviceName == "" { + serviceName = "N/A" + } + region := out.Region + if region == "" { + region = "N/A" + } + + var lastUsedDate *time.Time + if !out.LastUsedDate.IsZero() { + lastUsedDate = &out.LastUsedDate + } + + return &Response{Data: &types.GetAccessKeyLastUsedResponse{ + Result: types.GetAccessKeyLastUsedResult{ + UserName: out.UserName, + AccessKeyLastUsed: types.AccessKeyLastUsed{ + LastUsedDate: lastUsedDate, + ServiceName: serviceName, + Region: region, + }, + }, + }}, nil +} + +func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required ListAccessKeys parameter: UserName") + return nil, iamerr.MissingParameter("UserName") + } + if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + maxItems := int32(iamutil.DefaultMaxItems) + if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" { + parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) + if err != nil || parsed < 1 || parsed > iamutil.MaxListItems { + debuglogger.Logf("invalid ListAccessKeys MaxItems value %q: parse_error=%v", rawMaxItems, err) + return nil, iamerr.InvalidMaxItems(rawMaxItems) + } + maxItems = int32(parsed) + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListAccessKeys(ctx.Context(), storage.ListAccessKeysInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM access keys for user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListAccessKeysResponse{ + Result: types.ListAccessKeysResult{ + AccessKeyMetadata: types.AccessKeyMetadataList{Members: out.AccessKeys}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 28f52871..f3c2d001 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -52,14 +52,14 @@ const ( ErrInvalidRegion ErrMissingHostSignedHeader ErrInvalidClientTokenID - ErrInvalidContentLength ErrThrottling - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys + ErrInvalidAccessKeyIDChars + ErrDeleteConflict ) type APIError interface { @@ -123,7 +123,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "The request processing has failed because of an unknown error, exception or failure.", HTTPStatusCode: http.StatusInternalServerError, }, - ErrInvalidContentLength: { Type: TypeSender, Code: "InvalidRequest", @@ -136,7 +135,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Rate exceeded.", HTTPStatusCode: http.StatusBadRequest, }, - ErrMissingAuthenticationToken: { Type: TypeSender, Code: "MissingAuthenticationToken", @@ -155,7 +153,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "The security token included in the request is invalid.", HTTPStatusCode: http.StatusForbidden, }, - ErrIncompleteSignature: { Type: TypeSender, Code: "IncompleteSignature", @@ -174,7 +171,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Authorization header requires Credential, SignedHeaders, and Signature.", HTTPStatusCode: http.StatusBadRequest, }, - ErrSignatureDoesNotMatch: { Type: TypeSender, Code: "SignatureDoesNotMatch", @@ -211,7 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.", HTTPStatusCode: http.StatusForbidden, }, - ErrMissingUserNameValue: { Type: TypeSender, Code: "ValidationError", @@ -236,6 +231,18 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidAccessKeyIDChars: { + Type: TypeSender, + Code: "ValidationError", + Message: "The specified value for accessKeyId is invalid. It must contain only alphanumeric characters.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrDeleteConflict: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete access keys first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -334,6 +341,14 @@ func NoSuchEntityUser(userName string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The user with name %s cannot be found.", userName), http.StatusNotFound) } +func NoSuchEntityAccessKey(accessKeyID string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The Access Key with id %s cannot be found", accessKeyID), http.StatusNotFound) +} + +func AccessKeysLimitExceeded(maxKeys int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for AccessKeysPerUser: %d", maxKeys), http.StatusConflict) +} + func ValidationError(message string) Error { return newSenderError("ValidationError", message, http.StatusBadRequest) } @@ -362,6 +377,18 @@ func InvalidMaxItems(value string) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", value)) } +func AccessKeyIDTooShort(minLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length greater than or equal to %d", minLength)) +} + +func AccessKeyIDTooLong(maxLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length less than or equal to %d", maxLength)) +} + +func InvalidAccessKeyStatus(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'status' failed to satisfy constraint: Member must satisfy enum value set: [Active, Inactive]", value)) +} + func TagKeyTooLong(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", index)) } diff --git a/iamapi/internal/iamutil/access_key.go b/iamapi/internal/iamutil/access_key.go new file mode 100644 index 00000000..70457c4b --- /dev/null +++ b/iamapi/internal/iamutil/access_key.go @@ -0,0 +1,88 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "crypto/rand" + "encoding/base64" + "regexp" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const ( + AccessKeyStatusActive = "Active" + AccessKeyStatusInactive = "Inactive" + + accessKeyIDPrefix = "AKIA" + accessKeyIDRandomLen = 17 + minAccessKeyIDLen = 16 + maxAccessKeyIDLen = 128 + secretAccessKeyBytes = 30 +) + +var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`) + +// GenerateAccessKeyID returns a new cryptographically random IAM access key +// id in the AKIA… format. +func GenerateAccessKeyID() (string, error) { + id, err := generateAWSID(accessKeyIDPrefix, accessKeyIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM access key id: %v", err) + return "", err + } + return id, nil +} + +// GenerateSecretAccessKey returns a new cryptographically random 40 character +// secret access key. +func GenerateSecretAccessKey() (string, error) { + b := make([]byte, secretAccessKeyBytes) + if _, err := rand.Read(b); err != nil { + debuglogger.Logf("failed to generate IAM secret access key: %v", err) + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// ValidateAccessKeyID checks that accessKeyID fits within the allowed length +// range and character set. +func ValidateAccessKeyID(accessKeyID string) error { + if len(accessKeyID) < minAccessKeyIDLen { + debuglogger.Logf("IAM access key id too short: value=%q", accessKeyID) + return iamerr.AccessKeyIDTooShort(minAccessKeyIDLen) + } + if len(accessKeyID) > maxAccessKeyIDLen { + debuglogger.Logf("IAM access key id too long: value=%q", accessKeyID) + return iamerr.AccessKeyIDTooLong(maxAccessKeyIDLen) + } + if !accessKeyIDPattern.MatchString(accessKeyID) { + debuglogger.Logf("invalid IAM access key id characters: value=%q", accessKeyID) + return iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars) + } + + return nil +} + +// ValidateAccessKeyStatus checks that status is either Active or Inactive. +func ValidateAccessKeyStatus(status string) error { + if status != AccessKeyStatusActive && status != AccessKeyStatusInactive { + debuglogger.Logf("invalid IAM access key status: %q", status) + return iamerr.InvalidAccessKeyStatus(status) + } + + return nil +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 1922aa85..19bcb921 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -151,15 +151,25 @@ func BuildUserArn(accountID, path, userName string) string { // GenerateUserID returns a new cryptographically random IAM user ID in the AIDA… format. func GenerateUserID() (string, error) { + id, err := generateAWSID(userIDPrefix, userIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM user ID: %v", err) + return "", err + } + return id, nil +} + +// generateAWSID builds an AWS-style unique identifier: a fixed prefix +// followed by randomLen characters drawn from userIDAlphabet. +func generateAWSID(prefix string, randomLen int) (string, error) { var b strings.Builder - b.Grow(len(userIDPrefix) + userIDRandomLen) - b.WriteString(userIDPrefix) + b.Grow(len(prefix) + randomLen) + b.WriteString(prefix) max := big.NewInt(int64(len(userIDAlphabet))) - for range userIDRandomLen { + for range randomLen { n, err := rand.Int(rand.Reader, max) if err != nil { - debuglogger.Logf("failed to generate IAM user ID: %v", err) return "", err } b.WriteByte(userIDAlphabet[n.Int64()]) diff --git a/iamapi/router.go b/iamapi/router.go index 823cba05..ce6eb55c 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -45,11 +45,18 @@ func (r *IAMApiRouter) Init() { r.Ctrl = ctrl r.actions = map[string]ActionHandler{ + // User CRUD "CreateUser": ctrl.CreateUser, "DeleteUser": ctrl.DeleteUser, "GetUser": ctrl.GetUser, "ListUsers": ctrl.ListUsers, "UpdateUser": ctrl.UpdateUser, + // User Access Key CRUD + "CreateAccessKey": ctrl.CreateAccessKey, + "UpdateAccessKey": ctrl.UpdateAccessKey, + "DeleteAccessKey": ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": ctrl.ListAccessKeys, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index be4e0f09..3c3b1692 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -50,16 +50,25 @@ func NewInternal(dir string) (Storer, error) { type iamConfig struct { Users map[string]types.User `json:"users"` + // AccessKeyIndex maps an access key id to the username that owns it, + // so GetAccessKeyLastUsed can resolve a key without scanning every user. + AccessKeyIndex map[string]string `json:"accessKeyIndex"` } func defaultIAMConfig() iamConfig { - return iamConfig{Users: map[string]types.User{}} + return iamConfig{ + Users: map[string]types.User{}, + AccessKeyIndex: map[string]string{}, + } } func normalizeIAMConfig(conf *iamConfig) { if conf.Users == nil { conf.Users = make(map[string]types.User) } + if conf.AccessKeyIndex == nil { + conf.AccessKeyIndex = make(map[string]string) + } } func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { @@ -100,9 +109,13 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - if _, ok := conf.Users[username]; !ok { + user, ok := conf.Users[username] + if !ok { return nil, iamerr.NoSuchEntityUser(username) } + if len(user.AccessKeys) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } delete(conf.Users, username) return json.Marshal(conf) @@ -214,6 +227,9 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t if user.UserName != input.UserName { delete(conf.Users, input.UserName) + for _, key := range user.AccessKeys { + conf.AccessKeyIndex[key.AccessKeyId] = user.UserName + } } conf.Users[user.UserName] = user updated = user @@ -226,8 +242,212 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return cloneUser(updated), nil } +func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { + s.Lock() + defer s.Unlock() + + var created types.AccessKey + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + if _, ok := conf.AccessKeyIndex[input.AccessKeyID]; ok { + return nil, ErrAccessKeyIDAlreadyExists + } + + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + conf.Users[input.UserName] = user + conf.AccessKeyIndex[input.AccessKeyID] = input.UserName + + created = types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + } + + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return &created, nil +} + +func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKeyInput) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + found = true + break + } + } + if !found { + return nil, iamerr.NoSuchEntityAccessKey(input.AccessKeyID) + } + + conf.Users[input.UserName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityUser(username) + } + + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + conf.Users[username] = user + delete(conf.AccessKeyIndex, accessKeyID) + + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + username, ok := conf.AccessKeyIndex[accessKeyID] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return &GetAccessKeyLastUsedOutput{ + UserName: username, + LastUsedDate: key.LastUsedDate, + ServiceName: key.LastUsedService, + Region: key.LastUsedRegion, + }, nil + } + } + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) +} + +func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) + for _, key := range user.AccessKeys { + keys = append(keys, types.AccessKeyMetadata{ + UserName: input.UserName, + AccessKeyId: key.AccessKeyId, + Status: key.Status, + CreateDate: key.CreateDate, + }) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i].AccessKeyId < keys[j].AccessKeyId + }) + + start := 0 + if input.Marker != "" { + start = len(keys) + for i, key := range keys { + if key.AccessKeyId == input.Marker { + start = i + 1 + break + } + } + } + keys = keys[start:] + + limit := len(keys) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListAccessKeysOutput{ + AccessKeys: make([]types.AccessKeyMetadata, limit), + } + copy(out.AccessKeys, keys[:limit]) + if limit < len(keys) { + out.IsTruncated = true + out.Marker = out.AccessKeys[limit-1].AccessKeyId + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) + cloned.AccessKeys = slices.Clone(user.AccessKeys) return &cloned } diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index c2544815..7b8eaed9 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -19,13 +19,19 @@ import ( "errors" "fmt" "strings" + "time" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" ) +// MaxAccessKeysPerUser is the maximum number of access keys a single IAM +// user may hold at once, matching the AWS IAM quota. +const MaxAccessKeysPerUser = 2 + var ( - ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") ) type ListUsersInput struct { @@ -47,6 +53,39 @@ type UpdateUserInput struct { NewArn string } +type CreateAccessKeyInput struct { + UserName string + AccessKeyID string + SecretAccessKey string + Status string + CreateDate time.Time +} + +type UpdateAccessKeyInput struct { + UserName string + AccessKeyID string + Status string +} + +type ListAccessKeysInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListAccessKeysOutput struct { + AccessKeys []types.AccessKeyMetadata + IsTruncated bool + Marker string +} + +type GetAccessKeyLastUsedOutput struct { + UserName string + LastUsedDate time.Time + ServiceName string + Region string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -54,6 +93,12 @@ type Storer interface { GetUser(ctx context.Context, username string) (*types.User, error) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) + + CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) + UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error + DeleteAccessKey(ctx context.Context, username, accessKeyID string) error + GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) + ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index 6947cefa..be0ea36d 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -198,6 +198,22 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("reopened tags = %#v, want %#v", reopenedUser.Tags, users[0].Tags) } + if _, err := reopened.CreateAccessKey(ctx, CreateAccessKeyInput{ + UserName: "zoe", + AccessKeyID: "AKIAZZZZZZZZZZZZZZZZ", + SecretAccessKey: "secret", + Status: "Active", + CreateDate: created, + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflict)) { + t.Fatalf("DeleteUser with access keys err = %v, want DeleteConflict", err) + } + if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAZZZZZZZZZZZZZZZZ"); err != nil { + t.Fatalf("DeleteAccessKey: %v", err) + } + if err := reopened.DeleteUser(ctx, "zoe"); err != nil { t.Fatalf("DeleteUser: %v", err) } diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index de97762b..9c661db9 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "slices" "sort" "strings" "time" @@ -228,9 +229,13 @@ func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User } func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { - if _, err := s.GetUser(ctx, username); err != nil { + user, err := s.GetUser(ctx, username) + if err != nil { return err } + if len(user.AccessKeys) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } return s.deleteByPath(username) } @@ -366,17 +371,186 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty if err := s.deleteByPath(input.UserName); err != nil { return nil, err } - } else { - // Delete all versions then re-create so CAS=0 succeeds. - if err := s.deleteByPath(input.UserName); err != nil { - return nil, err + } else if _, err := s.replaceUser(ctx, *user); err != nil { + return nil, err + } + + return cloneUser(*user), nil +} + +// replaceUser overwrites the stored document for user.UserName by deleting +// all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceUser(ctx context.Context, user types.User) (*types.User, error) { + if err := s.deleteByPath(user.UserName); err != nil { + return nil, err + } + return s.CreateUser(ctx, user) +} + +func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + return nil, ErrAccessKeyIDAlreadyExists } - if _, err := s.CreateUser(ctx, *user); err != nil { + } + + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + + if _, err := s.replaceUser(ctx, *user); err != nil { + return nil, err + } + + return &types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + }, nil +} + +func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return err + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + found = true + break + } + } + if !found { + return iamerr.NoSuchEntityAccessKey(input.AccessKeyID) + } + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error { + user, err := s.GetUser(ctx, username) + if err != nil { + return err + } + + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } return nil, err } } - return cloneUser(*user), nil + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, err + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return &GetAccessKeyLastUsedOutput{ + UserName: username, + LastUsedDate: key.LastUsedDate, + ServiceName: key.LastUsedService, + Region: key.LastUsedRegion, + }, nil + } + } + } + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) +} + +func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) + for _, key := range user.AccessKeys { + keys = append(keys, types.AccessKeyMetadata{ + UserName: input.UserName, + AccessKeyId: key.AccessKeyId, + Status: key.Status, + CreateDate: key.CreateDate, + }) + } + sort.Slice(keys, func(i, j int) bool { + return keys[i].AccessKeyId < keys[j].AccessKeyId + }) + + start := 0 + if input.Marker != "" { + start = len(keys) + for i, key := range keys { + if key.AccessKeyId == input.Marker { + start = i + 1 + break + } + } + } + keys = keys[start:] + + limit := len(keys) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListAccessKeysOutput{ + AccessKeys: make([]types.AccessKeyMetadata, limit), + } + copy(out.AccessKeys, keys[:limit]) + if limit < len(keys) { + out.IsTruncated = true + out.Marker = out.AccessKeys[limit-1].AccessKeyId + } + + return out, nil } // deleteByPath permanently removes a secret and all its versions without diff --git a/iamapi/types/access_key.go b/iamapi/types/access_key.go new file mode 100644 index 00000000..9cc0ddfc --- /dev/null +++ b/iamapi/types/access_key.go @@ -0,0 +1,121 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +type CreateAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"` + Result CreateAccessKeyResult `xml:"CreateAccessKeyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateAccessKeyResult struct { + AccessKey AccessKey +} + +type AccessKey struct { + UserName string `xml:",omitempty"` + AccessKeyId string + Status string + SecretAccessKey string + CreateDate time.Time +} + +type UpdateAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteAccessKeyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteAccessKeyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetAccessKeyLastUsedResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetAccessKeyLastUsedResponse"` + Result GetAccessKeyLastUsedResult `xml:"GetAccessKeyLastUsedResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetAccessKeyLastUsedResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetAccessKeyLastUsedResult struct { + UserName string `xml:",omitempty"` + AccessKeyLastUsed AccessKeyLastUsed +} + +type AccessKeyLastUsed struct { + LastUsedDate *time.Time `xml:",omitempty"` + ServiceName string + Region string +} + +type ListAccessKeysResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"` + Result ListAccessKeysResult `xml:"ListAccessKeysResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListAccessKeysResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListAccessKeysResult struct { + AccessKeyMetadata AccessKeyMetadataList + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type AccessKeyMetadataList struct { + Members []AccessKeyMetadata `xml:"member"` +} + +type AccessKeyMetadata struct { + UserName string `xml:",omitempty"` + AccessKeyId string + Status string + CreateDate time.Time +} + +// AccessKeyEntry is the storage representation of an access key belonging to +// a User. It is never marshaled to XML directly; it round-trips through JSON +// for the internal and Vault storers. +type AccessKeyEntry struct { + AccessKeyId string + SecretAccessKey string + Status string + CreateDate time.Time + LastUsedDate time.Time + LastUsedService string + LastUsedRegion string +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index 40fdc2a8..9083ea2b 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -99,12 +99,13 @@ func (r *DeleteUserResponse) SetRequestID(requestID string) { } type User struct { - Path string `xml:",omitempty"` - UserName string `xml:",omitempty"` - UserID string `xml:"UserId"` - Arn string `xml:"Arn"` - CreateDate time.Time `xml:"CreateDate"` - Tags []Tag `xml:"Tags>member,omitempty"` + Path string `xml:",omitempty"` + UserName string `xml:",omitempty"` + UserID string `xml:"UserId"` + Arn string `xml:"Arn"` + CreateDate time.Time `xml:"CreateDate"` + Tags []Tag `xml:"Tags>member,omitempty"` + AccessKeys []AccessKeyEntry `xml:"-"` } type Tag struct { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 3fde9d1a..ac91884e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1159,6 +1159,7 @@ func TestIAMDeleteUser(ts *TestState) { ts.Run(IAMDeleteUser_invalid_user_name) ts.Run(IAMDeleteUser_long_user_name) ts.Run(IAMDeleteUser_non_existing_user) + ts.Run(IAMDeleteUser_has_access_keys) ts.Run(IAMDeleteUser_success) } @@ -1174,6 +1175,64 @@ func TestIAMUpdateUser(ts *TestState) { ts.Run(IAMUpdateUser_success) } +func TestIAMCreateAccessKey(ts *TestState) { + ts.Run(IAMCreateAccessKey_missing_user_name) + ts.Run(IAMCreateAccessKey_invalid_user_name) + ts.Run(IAMCreateAccessKey_long_user_name) + ts.Run(IAMCreateAccessKey_non_existing_user) + ts.Run(IAMCreateAccessKey_limit_exceeded) + ts.Run(IAMCreateAccessKey_success) +} + +func TestIAMUpdateAccessKey(ts *TestState) { + ts.Run(IAMUpdateAccessKey_missing_user_name) + ts.Run(IAMUpdateAccessKey_invalid_user_name) + ts.Run(IAMUpdateAccessKey_long_user_name) + ts.Run(IAMUpdateAccessKey_missing_access_key_id) + ts.Run(IAMUpdateAccessKey_access_key_id_too_short) + ts.Run(IAMUpdateAccessKey_access_key_id_too_long) + ts.Run(IAMUpdateAccessKey_invalid_access_key_id_chars) + ts.Run(IAMUpdateAccessKey_missing_status) + ts.Run(IAMUpdateAccessKey_invalid_status) + ts.Run(IAMUpdateAccessKey_non_existing_user) + ts.Run(IAMUpdateAccessKey_non_existing_access_key) + ts.Run(IAMUpdateAccessKey_success) +} + +func TestIAMDeleteAccessKey(ts *TestState) { + ts.Run(IAMDeleteAccessKey_missing_user_name) + ts.Run(IAMDeleteAccessKey_invalid_user_name) + ts.Run(IAMDeleteAccessKey_long_user_name) + ts.Run(IAMDeleteAccessKey_missing_access_key_id) + ts.Run(IAMDeleteAccessKey_access_key_id_too_short) + ts.Run(IAMDeleteAccessKey_access_key_id_too_long) + ts.Run(IAMDeleteAccessKey_invalid_access_key_id_chars) + ts.Run(IAMDeleteAccessKey_non_existing_user) + ts.Run(IAMDeleteAccessKey_non_existing_access_key) + ts.Run(IAMDeleteAccessKey_success) +} + +func TestIAMGetAccessKeyLastUsed(ts *TestState) { + ts.Run(IAMGetAccessKeyLastUsed_missing_access_key_id) + ts.Run(IAMGetAccessKeyLastUsed_access_key_id_too_short) + ts.Run(IAMGetAccessKeyLastUsed_access_key_id_too_long) + ts.Run(IAMGetAccessKeyLastUsed_invalid_access_key_id_chars) + ts.Run(IAMGetAccessKeyLastUsed_non_existing_access_key) + ts.Run(IAMGetAccessKeyLastUsed_success) +} + +func TestIAMListAccessKeys(ts *TestState) { + ts.Run(IAMListAccessKeys_missing_user_name) + ts.Run(IAMListAccessKeys_invalid_user_name) + ts.Run(IAMListAccessKeys_long_user_name) + ts.Run(IAMListAccessKeys_invalid_max_items) + ts.Run(IAMListAccessKeys_invalid_max_items_format) + ts.Run(IAMListAccessKeys_non_existing_user) + ts.Run(IAMListAccessKeys_empty_result) + ts.Run(IAMListAccessKeys_success) + ts.Run(IAMListAccessKeys_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1182,6 +1241,11 @@ func TestIAM(ts *TestState) { TestIAMListUsers(ts) TestIAMDeleteUser(ts) TestIAMUpdateUser(ts) + TestIAMCreateAccessKey(ts) + TestIAMUpdateAccessKey(ts) + TestIAMDeleteAccessKey(ts) + TestIAMGetAccessKeyLastUsed(ts) + TestIAMListAccessKeys(ts) } func TestAccessControl(ts *TestState) { @@ -1556,6 +1620,7 @@ func GetIntTests() IntTests { "IAMDeleteUser_invalid_user_name": IAMDeleteUser_invalid_user_name, "IAMDeleteUser_long_user_name": IAMDeleteUser_long_user_name, "IAMDeleteUser_non_existing_user": IAMDeleteUser_non_existing_user, + "IAMDeleteUser_has_access_keys": IAMDeleteUser_has_access_keys, "IAMDeleteUser_success": IAMDeleteUser_success, "IAMUpdateUser_invalid_user_name": IAMUpdateUser_invalid_user_name, "IAMUpdateUser_long_user_name": IAMUpdateUser_long_user_name, @@ -1566,6 +1631,49 @@ func GetIntTests() IntTests { "IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path, "IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists, "IAMUpdateUser_success": IAMUpdateUser_success, + "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, + "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, + "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, + "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, + "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, + "IAMCreateAccessKey_success": IAMCreateAccessKey_success, + "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, + "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, + "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, + "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, + "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, + "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, + "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, + "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, + "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, + "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, + "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, + "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, + "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, + "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, + "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, + "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, + "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, + "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, + "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, + "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, + "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, + "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, + "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, + "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, + "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, + "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, + "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, + "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, + "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, + "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, + "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, + "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, + "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, + "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, + "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, + "IAMListAccessKeys_success": IAMListAccessKeys_success, + "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_create_access_key.go b/tests/integration/iam_create_access_key.go new file mode 100644 index 00000000..78155743 --- /dev/null +++ b/tests/integration/iam_create_access_key.go @@ -0,0 +1,151 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +var integrationIAMAccessKeyIDPattern = regexp.MustCompile(`^AKIA[A-Z2-7]{17}$`) + +func IAMCreateAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{}) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMCreateAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{ + UserName: aws.String("invalid/user"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMCreateAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMCreateAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMCreateAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMCreateAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMCreateAccessKey_limit_exceeded(s *S3Conf) error { + testName := "IAMCreateAccessKey_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + for range 2 { + if _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}); err != nil { + return err + } + } + _, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.AccessKeysLimitExceeded(2)) + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateAccessKey_success(s *S3Conf) error { + testName := "IAMCreateAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + checkErr := func() error { + if err != nil { + return err + } + return checkCreateAccessKeyOutput(out, userName) + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func createIAMAccessKey(client *iam.Client, input *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateAccessKey(ctx, input) +} + +func checkCreateAccessKeyOutput(out *iam.CreateAccessKeyOutput, userName string) error { + if out == nil || out.AccessKey == nil { + return fmt.Errorf("expected CreateAccessKey output access key") + } + + key := out.AccessKey + if aws.ToString(key.UserName) != userName { + return fmt.Errorf("expected access key user name to be %q, instead got %q", userName, aws.ToString(key.UserName)) + } + if !integrationIAMAccessKeyIDPattern.MatchString(aws.ToString(key.AccessKeyId)) { + return fmt.Errorf("expected AWS IAM access key id, instead got %q", aws.ToString(key.AccessKeyId)) + } + if key.Status != iamtypes.StatusTypeActive { + return fmt.Errorf("expected access key status to be %q, instead got %q", iamtypes.StatusTypeActive, key.Status) + } + if aws.ToString(key.SecretAccessKey) == "" { + return fmt.Errorf("expected access key secret") + } + if key.CreateDate == nil || key.CreateDate.IsZero() { + return fmt.Errorf("expected access key create date") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected CreateAccessKey response request id") + } + + return nil +} diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go index 12a0a62e..9f64cae1 100644 --- a/tests/integration/iam_create_user.go +++ b/tests/integration/iam_create_user.go @@ -228,6 +228,23 @@ func deleteIAMUser(client *iam.Client, userName string) error { return err } +// deleteIAMUserAndAccessKeys deletes all of the user's access keys before +// deleting the user, since DeleteUser rejects users with access keys still +// attached. Use this for test cleanup after a test has created access keys; +// use deleteIAMUser directly when the test itself manages key deletion. +func deleteIAMUserAndAccessKeys(client *iam.Client, userName string) error { + out, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + for _, key := range out.AccessKeyMetadata { + if err := deleteIAMAccessKey(client, userName, aws.ToString(key.AccessKeyId)); err != nil { + return err + } + } + return deleteIAMUser(client, userName) +} + func newIAMUserName() string { return "create-user-" + genRandString(16) } diff --git a/tests/integration/iam_delete_access_key.go b/tests/integration/iam_delete_access_key.go new file mode 100644 index 00000000..36897bfb --- /dev/null +++ b/tests/integration/iam_delete_access_key.go @@ -0,0 +1,169 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "", genRandString(20)) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMDeleteAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "invalid/user", genRandString(20)) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMDeleteAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMDeleteAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, strings.Repeat("a", 129), genRandString(20)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMDeleteAccessKey_missing_access_key_id(s *S3Conf) error { + testName := "IAMDeleteAccessKey_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"DeleteAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMDeleteAccessKey_access_key_id_too_short(s *S3Conf) error { + testName := "IAMDeleteAccessKey_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", genRandString(15)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMDeleteAccessKey_access_key_id_too_long(s *S3Conf) error { + testName := "IAMDeleteAccessKey_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", genRandString(129)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMDeleteAccessKey_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMDeleteAccessKey_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMAccessKey(client, "validusername", "invalid-key-id-1234") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMDeleteAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMDeleteAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + err := deleteIAMAccessKey(client, userName, genRandString(20)) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMDeleteAccessKey_non_existing_access_key(s *S3Conf) error { + testName := "IAMDeleteAccessKey_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + accessKeyID := genRandString(20) + deleteErr := deleteIAMAccessKey(client, userName, accessKeyID) + checkErr := checkIAMApiErr(deleteErr, iamerr.NoSuchEntityAccessKey(accessKeyID)) + + userDeleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return userDeleteErr + }) +} + +func IAMDeleteAccessKey_success(s *S3Conf) error { + testName := "IAMDeleteAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + if err := deleteIAMAccessKey(client, userName, accessKeyID); err != nil { + return err + } + + _, err = getIAMAccessKeyLastUsed(client, accessKeyID) + return checkIAMApiErr(err, iamerr.NoSuchEntityAccessKey(accessKeyID)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func deleteIAMAccessKey(client *iam.Client, userName, accessKeyID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + input := &iam.DeleteAccessKeyInput{AccessKeyId: &accessKeyID} + if userName != "" { + input.UserName = &userName + } + _, err := client.DeleteAccessKey(ctx, input) + return err +} diff --git a/tests/integration/iam_delete_user.go b/tests/integration/iam_delete_user.go index 1271b1dd..f618101c 100644 --- a/tests/integration/iam_delete_user.go +++ b/tests/integration/iam_delete_user.go @@ -47,6 +47,35 @@ func IAMDeleteUser_non_existing_user(s *S3Conf) error { }) } +func IAMDeleteUser_has_access_keys(s *S3Conf) error { + testName := "IAMDeleteUser_has_access_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(out.AccessKey.AccessKeyId) + + checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflict)) + + deleteKeyErr := deleteIAMAccessKey(client, userName, accessKeyID) + deleteUserErr := deleteIAMUser(client, userName) + + if checkErr != nil { + return checkErr + } + if deleteKeyErr != nil { + return deleteKeyErr + } + return deleteUserErr + }) +} + func IAMDeleteUser_success(s *S3Conf) error { testName := "IAMDeleteUser_success" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_get_access_key_last_used.go b/tests/integration/iam_get_access_key_last_used.go new file mode 100644 index 00000000..2a496f8a --- /dev/null +++ b/tests/integration/iam_get_access_key_last_used.go @@ -0,0 +1,137 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetAccessKeyLastUsed_missing_access_key_id(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"GetAccessKeyLastUsed"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMGetAccessKeyLastUsed_access_key_id_too_short(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, genRandString(15)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMGetAccessKeyLastUsed_access_key_id_too_long(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, genRandString(129)) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMGetAccessKeyLastUsed_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMAccessKeyLastUsed(client, "invalid-key-id-1234") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMGetAccessKeyLastUsed_non_existing_access_key(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + accessKeyID := genRandString(20) + _, err := getIAMAccessKeyLastUsed(client, accessKeyID) + return checkIAMApiErr(err, iamerr.NoSuchEntityAccessKey(accessKeyID)) + }) +} + +func IAMGetAccessKeyLastUsed_success(s *S3Conf) error { + testName := "IAMGetAccessKeyLastUsed_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + out, err := getIAMAccessKeyLastUsed(client, accessKeyID) + if err != nil { + return err + } + if out == nil || out.AccessKeyLastUsed == nil { + return fmt.Errorf("expected GetAccessKeyLastUsed output") + } + if aws.ToString(out.UserName) != userName { + return fmt.Errorf("expected access key user name to be %q, instead got %q", userName, aws.ToString(out.UserName)) + } + if aws.ToString(out.AccessKeyLastUsed.ServiceName) != "N/A" { + return fmt.Errorf("expected access key last used service name to be %q, instead got %q", "N/A", aws.ToString(out.AccessKeyLastUsed.ServiceName)) + } + if aws.ToString(out.AccessKeyLastUsed.Region) != "N/A" { + return fmt.Errorf("expected access key last used region to be %q, instead got %q", "N/A", aws.ToString(out.AccessKeyLastUsed.Region)) + } + if out.AccessKeyLastUsed.LastUsedDate != nil { + return fmt.Errorf("expected no access key last used date, instead got %v", out.AccessKeyLastUsed.LastUsedDate) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetAccessKeyLastUsed response request id") + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMAccessKeyLastUsed(client *iam.Client, accessKeyID string) (*iam.GetAccessKeyLastUsedOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetAccessKeyLastUsed(ctx, &iam.GetAccessKeyLastUsedInput{AccessKeyId: &accessKeyID}) +} diff --git a/tests/integration/iam_list_access_keys.go b/tests/integration/iam_list_access_keys.go new file mode 100644 index 00000000..87efa59b --- /dev/null +++ b/tests/integration/iam_list_access_keys.go @@ -0,0 +1,331 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "reflect" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListAccessKeys_missing_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{}) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMListAccessKeys_invalid_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: aws.String("invalid/user"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMListAccessKeys_long_user_name(s *S3Conf) error { + testName := "IAMListAccessKeys_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMListAccessKeys_invalid_max_items(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + for _, maxItems := range []int32{-1, 0, 1001} { + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ + UserName: &userName, + MaxItems: aws.Int32(maxItems), + }) + expected := iamerr.InvalidMaxItems(fmt.Sprint(maxItems)) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListAccessKeys_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListAccessKeys_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListAccessKeys"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") + return checkIAMAuthRequest(s, req, expected) + }) +} + +func IAMListAccessKeys_non_existing_user(s *S3Conf) error { + testName := "IAMListAccessKeys_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListAccessKeys_empty_result(s *S3Conf) error { + testName := "IAMListAccessKeys_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if err := checkIAMListAccessKeysOutput(out); err != nil { + return err + } + if len(out.AccessKeyMetadata) != 0 { + return fmt.Errorf("expected no access keys, instead got %d", len(out.AccessKeyMetadata)) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListAccessKeys_success(s *S3Conf) error { + testName := "IAMListAccessKeys_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + expected := map[string]iamtypes.StatusType{} + for range 2 { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + expected[aws.ToString(created.AccessKey.AccessKeyId)] = iamtypes.StatusTypeActive + } + + first, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + second, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if err := checkIAMListAccessKeysOutput(first); err != nil { + return err + } + if err := checkIAMListAccessKeys(first.AccessKeyMetadata, userName, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListAccessKeyIDs(first.AccessKeyMetadata), iamListAccessKeyIDs(second.AccessKeyMetadata)) { + return fmt.Errorf("expected consistent results across calls") + } + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListAccessKeys_pagination(s *S3Conf) error { + testName := "IAMListAccessKeys_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + expected := map[string]iamtypes.StatusType{} + for range 2 { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + expected[aws.ToString(created.AccessKey.AccessKeyId)] = iamtypes.StatusTypeActive + } + + input := iam.ListAccessKeysInput{UserName: &userName, MaxItems: aws.Int32(1)} + firstPages, err := collectIAMListAccessKeyPages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListAccessKeyPages(client, input) + if err != nil { + return err + } + if len(firstPages) != 2 { + return fmt.Errorf("expected 2 pages, instead got %d", len(firstPages)) + } + var allKeys []iamtypes.AccessKeyMetadata + for i, page := range firstPages { + if len(page.AccessKeyMetadata) != 1 { + return fmt.Errorf("expected page %d to contain 1 access key, instead got %d", i+1, len(page.AccessKeyMetadata)) + } + if page.IsTruncated != (i < len(firstPages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + allKeys = append(allKeys, page.AccessKeyMetadata...) + } + if err := checkIAMListAccessKeys(allKeys, userName, expected); err != nil { + return err + } + + var firstIDs, secondIDs [][]string + for _, page := range firstPages { + firstIDs = append(firstIDs, append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListAccessKeyIDs(page.AccessKeyMetadata)...)) + } + for _, page := range secondPages { + secondIDs = append(secondIDs, append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListAccessKeyIDs(page.AccessKeyMetadata)...)) + } + if !reflect.DeepEqual(firstIDs, secondIDs) { + return fmt.Errorf("expected consistent pagination results") + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMAccessKeys(client *iam.Client, input *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListAccessKeys(ctx, input) +} + +func collectIAMListAccessKeyPages(client *iam.Client, input iam.ListAccessKeysInput) ([]*iam.ListAccessKeysOutput, error) { + var pages []*iam.ListAccessKeysOutput + for { + out, err := listIAMAccessKeys(client, &input) + if err != nil { + return nil, err + } + if err := checkIAMListAccessKeysOutput(out); err != nil { + return nil, err + } + pages = append(pages, out) + if !out.IsTruncated { + return pages, nil + } + input.Marker = out.Marker + } +} + +func checkIAMListAccessKeysOutput(out *iam.ListAccessKeysOutput) error { + if out == nil { + return fmt.Errorf("expected ListAccessKeys output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListAccessKeys response request id") + } + if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") { + return fmt.Errorf("expected marker only when ListAccessKeys output is truncated") + } + for _, key := range out.AccessKeyMetadata { + if aws.ToString(key.UserName) == "" || aws.ToString(key.AccessKeyId) == "" || key.CreateDate == nil || key.CreateDate.IsZero() { + return fmt.Errorf("expected all required fields for listed access key, instead got %#v", key) + } + if !integrationIAMAccessKeyIDPattern.MatchString(aws.ToString(key.AccessKeyId)) { + return fmt.Errorf("expected AWS IAM access key id, instead got %q", aws.ToString(key.AccessKeyId)) + } + } + return nil +} + +func checkIAMListAccessKeys(keys []iamtypes.AccessKeyMetadata, userName string, expected map[string]iamtypes.StatusType) error { + if len(keys) != len(expected) { + return fmt.Errorf("expected %d access keys, instead got %d: %v", len(expected), len(keys), iamListAccessKeyIDs(keys)) + } + ids := iamListAccessKeyIDs(keys) + if !sort.StringsAreSorted(ids) { + return fmt.Errorf("expected access keys sorted by access key id, instead got %v", ids) + } + for _, key := range keys { + id := aws.ToString(key.AccessKeyId) + status, ok := expected[id] + if !ok { + return fmt.Errorf("unexpected listed access key %q", id) + } + if aws.ToString(key.UserName) != userName { + return fmt.Errorf("expected access key %q user name %q, instead got %q", id, userName, aws.ToString(key.UserName)) + } + if key.Status != status { + return fmt.Errorf("expected access key %q status %q, instead got %q", id, status, key.Status) + } + } + return nil +} + +func iamListAccessKeyIDs(keys []iamtypes.AccessKeyMetadata) []string { + ids := make([]string, len(keys)) + for i, key := range keys { + ids[i] = aws.ToString(key.AccessKeyId) + } + return ids +} diff --git a/tests/integration/iam_update_access_key.go b/tests/integration/iam_update_access_key.go new file mode 100644 index 00000000..0170054b --- /dev/null +++ b/tests/integration/iam_update_access_key.go @@ -0,0 +1,254 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUpdateAccessKey_missing_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.MissingParameter("UserName")) + }) +} + +func IAMUpdateAccessKey_invalid_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("invalid/user"), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMUpdateAccessKey_long_user_name(s *S3Conf) error { + testName := "IAMUpdateAccessKey_long_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String(strings.Repeat("a", 129)), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMUpdateAccessKey_missing_access_key_id(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_access_key_id" + body := []byte(url.Values{ + "Action": {"UpdateAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Status": {"Active"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("AccessKeyId")) + }) +} + +func IAMUpdateAccessKey_access_key_id_too_short(s *S3Conf) error { + testName := "IAMUpdateAccessKey_access_key_id_too_short" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(15)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooShort(16)) + }) +} + +func IAMUpdateAccessKey_access_key_id_too_long(s *S3Conf) error { + testName := "IAMUpdateAccessKey_access_key_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(129)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.AccessKeyIDTooLong(128)) + }) +} + +func IAMUpdateAccessKey_invalid_access_key_id_chars(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_access_key_id_chars" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String("invalid-key-id-1234"), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidAccessKeyIDChars)) + }) +} + +func IAMUpdateAccessKey_missing_status(s *S3Conf) error { + testName := "IAMUpdateAccessKey_missing_status" + body := []byte(url.Values{ + "Action": {"UpdateAccessKey"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "AccessKeyId": {genRandString(20)}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("Status")) + }) +} + +func IAMUpdateAccessKey_invalid_status(s *S3Conf) error { + testName := "IAMUpdateAccessKey_invalid_status" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: aws.String("validusername"), + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusType("Bogus"), + }) + return checkIAMApiErr(err, iamerr.InvalidAccessKeyStatus("Bogus")) + }) +} + +func IAMUpdateAccessKey_non_existing_user(s *S3Conf) error { + testName := "IAMUpdateAccessKey_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: aws.String(genRandString(20)), + Status: iamtypes.StatusTypeActive, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMUpdateAccessKey_non_existing_access_key(s *S3Conf) error { + testName := "IAMUpdateAccessKey_non_existing_access_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + accessKeyID := genRandString(20) + _, updateErr := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: &accessKeyID, + Status: iamtypes.StatusTypeActive, + }) + checkErr := checkIAMApiErr(updateErr, iamerr.NoSuchEntityAccessKey(accessKeyID)) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateAccessKey_success(s *S3Conf) error { + testName := "IAMUpdateAccessKey_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + created, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + accessKeyID := aws.ToString(created.AccessKey.AccessKeyId) + + out, err := updateIAMAccessKey(client, &iam.UpdateAccessKeyInput{ + UserName: &userName, + AccessKeyId: &accessKeyID, + Status: iamtypes.StatusTypeInactive, + }) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected UpdateAccessKey output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UpdateAccessKey response request id") + } + + listOut, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{UserName: &userName}) + if err != nil { + return err + } + if len(listOut.AccessKeyMetadata) != 1 { + return fmt.Errorf("expected 1 access key, instead got %d", len(listOut.AccessKeyMetadata)) + } + if listOut.AccessKeyMetadata[0].Status != iamtypes.StatusTypeInactive { + return fmt.Errorf("expected access key status to be %q, instead got %q", iamtypes.StatusTypeInactive, listOut.AccessKeyMetadata[0].Status) + } + + return nil + }() + + deleteErr := deleteIAMUserAndAccessKeys(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateIAMAccessKey(client *iam.Client, input *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UpdateAccessKey(ctx, input) +} From 89b64910f3a09c66ff7ff0f65b8a94f2a830b636 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Fri, 10 Jul 2026 03:17:23 +0400 Subject: [PATCH 2/7] feat: add IAM user inline policy CRUD Add support for AWS-compatible inline identity-based policies on IAM users, implementing the `PutUserPolicy`, `GetUserPolicy`, `DeleteUserPolicy`, and `ListUserPolicies` actions on both the internal and Vault storage backends. - iamapi/policy is a new package that parses and validates policy documents against IAM's parameter-level constraints (max length, allowed charset) and policy grammar (Version, Effect, mutually exclusive Action/NotAction and Resource/NotResource, vendor-prefixed actions, ARN-shaped resources, no Principal/NotPrincipal, unique Sids). - `PutUserPolicy` creates or replaces a named inline policy on a user, enforcing a 2048-byte aggregate quota across all of a user's inline policies (MaxInlinePolicyBytesPerUser), matching the AWS IAM quota. - `GetUserPolicy` returns a policy's document RFC 3986 percent-encoded, matching how real IAM encodes the PolicyDocument response element. - `DeleteUserPolicy` removes a named inline policy from a user. - `ListUserPolicies` returns a paginated, sorted list of a user's inline policy names, honoring Marker/MaxItems like the other IAM list APIs. - `DeleteUser` is now rejected with a DeleteConflict error if the user still has inline policies attached, mirroring the existing access-key delete-conflict behavior. --- iamapi/controller.go | 216 +++++++---- iamapi/controller_test.go | 369 +++++++++++++++++++ iamapi/iamerr/errors.go | 38 +- iamapi/internal/iamutil/policy.go | 29 ++ iamapi/internal/iamutil/user.go | 57 ++- iamapi/policy/document.go | 103 ++++++ iamapi/policy/document_test.go | 114 ++++++ iamapi/policy/validate.go | 227 ++++++++++++ iamapi/policy/validate_test.go | 123 +++++++ iamapi/router.go | 5 + iamapi/storage/internal.go | 158 ++++++++ iamapi/storage/storer.go | 27 ++ iamapi/storage/vault.go | 119 +++++++ iamapi/types/policy.go | 96 +++++ iamapi/types/user.go | 1 + tests/integration/group-tests.go | 74 ++++ tests/integration/iam_delete_user_policy.go | 203 +++++++++++ tests/integration/iam_get_user_policy.go | 165 +++++++++ tests/integration/iam_list_user_policies.go | 223 ++++++++++++ tests/integration/iam_put_user_policy.go | 376 ++++++++++++++++++++ 20 files changed, 2645 insertions(+), 78 deletions(-) create mode 100644 iamapi/internal/iamutil/policy.go create mode 100644 iamapi/policy/document.go create mode 100644 iamapi/policy/document_test.go create mode 100644 iamapi/policy/validate.go create mode 100644 iamapi/policy/validate_test.go create mode 100644 iamapi/types/policy.go create mode 100644 tests/integration/iam_delete_user_policy.go create mode 100644 tests/integration/iam_get_user_policy.go create mode 100644 tests/integration/iam_list_user_policies.go create mode 100644 tests/integration/iam_put_user_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index 9718836e..ef4b5086 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,13 +17,13 @@ package iamapi import ( "errors" "fmt" - "strconv" "time" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/policy" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/iamapi/types" ) @@ -37,12 +37,8 @@ func NewController(store storage.Storer) IAMApiController { } func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok { - debuglogger.Logf("missing required CreateUser parameter: UserName") - return nil, iamerr.GetAPIError(iamerr.ErrMissingUserNameValue) - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserNameLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "CreateUser", iamutil.MaxUserNameLen, iamerr.MissingValue("userName")) + if err != nil { return nil, err } @@ -95,12 +91,8 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) { - username, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || username == "" { - debuglogger.Logf("missing required DeleteUser parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + username, err := iamutil.GetUserName(ctx, "DeleteUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -126,7 +118,7 @@ func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) { }}, }}, nil } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err } @@ -150,14 +142,9 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { return nil, err } - maxItems := int32(iamutil.DefaultMaxItems) - if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" { - parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) - if err != nil || parsed < 1 || parsed > iamutil.MaxListItems { - debuglogger.Logf("invalid ListUsers MaxItems value %q: parse_error=%v", rawMaxItems, err) - return nil, iamerr.InvalidMaxItems(rawMaxItems) - } - maxItems = int32(parsed) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUsers") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -181,12 +168,8 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { - username, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || username == "" { - debuglogger.Logf("missing required UpdateUser parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + username, err := iamutil.GetUserName(ctx, "UpdateUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -198,7 +181,7 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") if newUserName != "" { - if err := iamutil.ValidateUserName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { + if err := iamutil.ValidateName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { return nil, err } } @@ -235,12 +218,8 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required CreateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -277,18 +256,14 @@ func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { }, nil } - err := fmt.Errorf("generate IAM access key id: exhausted collision retries") + err = fmt.Errorf("generate IAM access key id: exhausted collision retries") debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) return nil, err } func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required UpdateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "UpdateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -323,12 +298,8 @@ func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required DeleteAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "DeleteAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -392,23 +363,14 @@ func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error) } func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required ListAccessKeys parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "ListAccessKeys", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } - maxItems := int32(iamutil.DefaultMaxItems) - if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" { - parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) - if err != nil || parsed < 1 || parsed > iamutil.MaxListItems { - debuglogger.Logf("invalid ListAccessKeys MaxItems value %q: parse_error=%v", rawMaxItems, err) - return nil, iamerr.InvalidMaxItems(rawMaxItems) - } - maxItems = int32(parsed) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListAccessKeys") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -430,3 +392,133 @@ func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) PutUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "PutUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + // Confirm the user exists before inspecting policy document content + if _, err := c.store.GetUser(ctx.Context(), userName); err != nil { + debuglogger.Logf("failed to get IAM user %q for PutUserPolicy: %v", userName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutUserPolicy(ctx.Context(), storage.PutUserPolicyInput{ + UserName: userName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.PutUserPolicyResponse{}}, nil +} + +func (c IAMApiController) GetUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "GetUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetUserPolicy(ctx.Context(), userName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.GetUserPolicyResponse{ + Result: types.GetUserPolicyResult{ + UserName: userName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "DeleteUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteUserPolicy(ctx.Context(), userName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.DeleteUserPolicyResponse{}}, nil +} + +func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "ListUserPolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserPolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListUserPolicies(ctx.Context(), storage.ListUserPoliciesInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM user policies for user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListUserPoliciesResponse{ + Result: types.ListUserPoliciesResult{ + PolicyNames: types.PolicyNameList{Members: out.PolicyNames}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 88b59f45..58cf4db8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" @@ -478,6 +479,355 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.") } +func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createUser := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"alice"}, + }) + if createUser.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", createUser.StatusCode, readBody(t, createUser)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutUserPolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutUserPolicyResponse" { + t.Fatalf("PutUserPolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutUserPolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetUserPolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.UserName != "alice" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetUserPolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument) + } + decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if decoded != policyDoc { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListUserPolicies"}, + "UserName": {"alice"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListUserPolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListUserPoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListUserPolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListUserPolicies IsTruncated = true, want false") + } + + // Re-Put-ing the same PolicyName replaces it rather than erroring or + // stacking toward the aggregate size quota. + overwritePut := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutUserPolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut) + overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if !strings.Contains(overwriteDecoded, "Deny") { + t.Fatalf("GetUserPolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteUserPolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteUserPolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteUserPolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") + + // A second delete of the same (now-gone) policy is a hard error, not an + // idempotent success. + doubleDelete := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupUser bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing user name", + params: url.Values{"Action": {"PutUserPolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "put long policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "put non-ascii policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.", + }, + { + name: "put user does not exist", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put nonexistent user wins over malformed document", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": { + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, + }}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Policy document should not specify a principal.", + }, + { + name: "put policy document exceeds aggregate size quota", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {oversizedDoc}}, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Maximum policy size of 2048 bytes exceeded for user alice", + }, + { + name: "get user does not exist", + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupUser: true, + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete user does not exist", + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupUser: true, + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list user does not exist", + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupUser: true, + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupUser { + resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerPutUserPolicyOversizedDocument(t *testing.T) { + // A >131072 byte PolicyDocument does not fit in a GET query string + // against this test server's header/URL read-buffer limit, matching + // real IAM's own guidance to use POST rather than GET for large + // policy documents - so this one case is exercised over POST directly + // rather than through the doIAMAction GET helper used elsewhere. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {strings.Repeat("x", 131073)}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072") +} + +func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deletePolicyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deletePolicyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + // When both an access key and a policy are attached, the policy + // conflict is reported first. + createKey := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"alice"}}) + if createKey.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey status = %d, body=%s", createKey.StatusCode, readBody(t, createKey)) + } + deleteBoth := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteBoth, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteKeyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() @@ -506,6 +856,25 @@ func doIAMAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Re return resp } +// doIAMActionPost signs and sends params as a POST form body rather than a +// GET query string, for requests too large to fit a GET request's +// header/URL buffer (e.g. an oversized PolicyDocument). +func doIAMActionPost(t *testing.T, server *IAMApiServer, params url.Values) *http.Response { + t.Helper() + if !params.Has("Version") { + params.Set("Version", iamAPIVersion) + } + + req := signedIAMRequest(t, http.MethodPost, "http://example.com/", []byte(params.Encode()), testRoot.Secret) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + func unmarshalXML(t *testing.T, body string, out any) { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index f3c2d001..5c55c6dd 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -54,12 +54,12 @@ const ( ErrInvalidClientTokenID ErrInvalidContentLength ErrThrottling - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys ErrInvalidAccessKeyIDChars ErrDeleteConflict + ErrDeleteConflictPolicies ) type APIError interface { @@ -207,12 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.", HTTPStatusCode: http.StatusForbidden, }, - ErrMissingUserNameValue: { - Type: TypeSender, - Code: "ValidationError", - Message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", - HTTPStatusCode: http.StatusBadRequest, - }, ErrInvalidPathPrefix: { Type: TypeSender, Code: "ValidationError", @@ -243,6 +237,12 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Cannot delete entity, must delete access keys first.", HTTPStatusCode: http.StatusConflict, }, + ErrDeleteConflictPolicies: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete policies first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -405,6 +405,30 @@ func InvalidTagValue(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*", index)) } +func MissingValue(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must not be null", field)) +} + +func ValueTooLong(field string, maxLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength)) +} + +func InvalidCharset(field string) Error { + return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) +} + +func MalformedPolicyDocument(message string) Error { + return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) +} + +func NoSuchEntityUserPolicy(userName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) +} + +func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/policy.go b/iamapi/internal/iamutil/policy.go new file mode 100644 index 00000000..9636a12e --- /dev/null +++ b/iamapi/internal/iamutil/policy.go @@ -0,0 +1,29 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "net/url" + "strings" +) + +// EncodePolicyDocument RFC 3986 percent-encodes a policy document string +// the way real IAM encodes the PolicyDocument element of GetUserPolicy (and +// will for GetRolePolicy) responses: every character outside the unreserved +// set is percent-encoded, with the space character encoded as %20 rather +// than the "+" that url.QueryEscape alone would produce. +func EncodePolicyDocument(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 19bcb921..68bcb667 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -19,6 +19,7 @@ import ( "fmt" "math/big" "regexp" + "strconv" "strings" "github.com/gofiber/fiber/v3" @@ -43,9 +44,9 @@ const ( ) var ( - userNamePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`) - tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) - tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) + namePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`) + tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) + tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) ) // RequestParam looks up key first in URL query args, then in the POST body. @@ -63,6 +64,42 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) { return "", false } +// GetUserName resolves the UserName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +// operation is included in the debug log on failure (e.g. "DeleteUser"). +// missingErr lets callers match the exact AWS error their operation is +// verified against (e.g. iamerr.MissingValue vs iamerr.MissingParameter). +func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + userName, ok := RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required %s parameter: UserName", operation) + return "", missingErr + } + if err := ValidateName("userName", userName, maxLen); err != nil { + return "", err + } + + return userName, nil +} + +// ParseMaxItems reads the MaxItems request parameter, defaulting to +// DefaultMaxItems when absent. operation is included in the debug log on +// parse failure (e.g. "ListUsers", "ListAccessKeys"). +func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) { + rawMaxItems, ok := RequestParam(ctx, "MaxItems") + if !ok || rawMaxItems == "" { + return int32(DefaultMaxItems), nil + } + + parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) + if err != nil || parsed < 1 || parsed > MaxListItems { + debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err) + return 0, iamerr.InvalidMaxItems(rawMaxItems) + } + + return int32(parsed), nil +} + // ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list. func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { var tags []types.Tag @@ -106,14 +143,16 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { return tags, nil } -// ValidateUserName checks that userName is non-empty, matches the allowed character set, and fits within maxLength. -func ValidateUserName(field, userName string, maxLength int) error { - if len(userName) > maxLength { - debuglogger.Logf("IAM user name exceeds maximum length: field=%s length=%d max=%d", field, len(userName), maxLength) +// ValidateName checks that name (an IAM identity or policy name, e.g. +// userName or policyName) is non-empty, matches the allowed character set, +// and fits within maxLength. +func ValidateName(field, name string, maxLength int) error { + if len(name) > maxLength { + debuglogger.Logf("IAM name exceeds maximum length: field=%s length=%d max=%d", field, len(name), maxLength) return iamerr.UserNameTooLong(field, maxLength) } - if userName == "" || !userNamePattern.MatchString(userName) { - debuglogger.Logf("invalid IAM user name: field=%s value=%q", field, userName) + if name == "" || !namePattern.MatchString(name) { + debuglogger.Logf("invalid IAM name: field=%s value=%q", field, name) return iamerr.InvalidUserName(field) } diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go new file mode 100644 index 00000000..39a7bc04 --- /dev/null +++ b/iamapi/policy/document.go @@ -0,0 +1,103 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "bytes" + "encoding/json" +) + +// Recognized values for a policy document's Version element. +const ( + Version2008 = "2008-10-17" + Version2012 = "2012-10-17" +) + +// Document is a parsed AWS IAM policy document. +type Document struct { + Version string + Statement []Statement +} + +// Statement is a single element of a policy document's Statement list. +type Statement struct { + Sid string + Effect string + Action StringOrSlice + NotAction StringOrSlice + Resource StringOrSlice + NotResource StringOrSlice + Principal json.RawMessage + NotPrincipal json.RawMessage +} + +// UnmarshalJSON accepts Statement as either a single JSON object or an +// array of objects, matching the AWS IAM policy grammar. A missing or +// JSON-null Statement leaves Document.Statement nil rather than erroring +// here — Validate reports that as a grammar error so all "empty document" +// shapes produce the same message. +func (d *Document) UnmarshalJSON(data []byte) error { + var raw struct { + Version string + Statement json.RawMessage + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + d.Version = raw.Version + + if len(raw.Statement) == 0 || string(bytes.TrimSpace(raw.Statement)) == "null" { + return nil + } + + var stmts []Statement + if err := json.Unmarshal(raw.Statement, &stmts); err == nil { + d.Statement = stmts + return nil + } + + var single Statement + if err := json.Unmarshal(raw.Statement, &single); err != nil { + return err + } + d.Statement = []Statement{single} + return nil +} + +// StringOrSlice decodes a JSON value that may be either a single string or +// an array of strings, matching the AWS IAM policy grammar for Action, +// NotAction, Resource, and NotResource. A JSON-null value decodes to a nil +// StringOrSlice, identical to the key being absent. +type StringOrSlice []string + +func (s *StringOrSlice) UnmarshalJSON(data []byte) error { + if string(bytes.TrimSpace(data)) == "null" { + *s = nil + return nil + } + + var single string + if err := json.Unmarshal(data, &single); err == nil { + *s = StringOrSlice{single} + return nil + } + + var multi []string + if err := json.Unmarshal(data, &multi); err != nil { + return err + } + *s = StringOrSlice(multi) + return nil +} diff --git a/iamapi/policy/document_test.go b/iamapi/policy/document_test.go new file mode 100644 index 00000000..bf9437b2 --- /dev/null +++ b/iamapi/policy/document_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestStringOrSliceUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want StringOrSlice + }{ + {"single string", `"s3:GetObject"`, StringOrSlice{"s3:GetObject"}}, + {"array of strings", `["s3:GetObject","s3:PutObject"]`, StringOrSlice{"s3:GetObject", "s3:PutObject"}}, + {"empty array", `[]`, StringOrSlice{}}, + {"empty string", `""`, StringOrSlice{""}}, + {"null", `null`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got StringOrSlice + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Unmarshal() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDocumentUnmarshalJSON(t *testing.T) { + t.Run("statement as array", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement as single object", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement absent leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17"}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("statement null leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":null}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("version absent leaves empty string, not defaulted", func(t *testing.T) { + // Unlike auth's S3 bucket-policy engine (which defaults a missing + // Version to 2008-10-17), real IAM leaves an omitted Version on an + // identity policy exactly as submitted - no default is injected. + var doc Document + err := json.Unmarshal([]byte(`{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Version != "" { + t.Fatalf("Version = %q, want empty", doc.Version) + } + }) + + t.Run("top-level non-object is an unmarshal error", func(t *testing.T) { + var doc Document + if err := json.Unmarshal([]byte(`"hello"`), &doc); err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) +} diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go new file mode 100644 index 00000000..8e14b06b --- /dev/null +++ b/iamapi/policy/validate.go @@ -0,0 +1,227 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// MaxDocumentLength is IAM's parameter-level maximum length for a +// PolicyDocument value. +const MaxDocumentLength = 131072 + +// vendorPattern is the inferred grammar for the service prefix of a policy +// action/resource (the text before the first ':', e.g. "s3", "iam", +// "elasticloadbalancing"). AWS does not publish this pattern; alphanumeric +// + hyphen matches every real service prefix and was verified to reject an +// empty or space-containing prefix the same way live IAM does. +var vendorPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +// validPartition is the only ARN partition name supported byt the gateway: real +// IAM also accepts "aws-cn", "aws-us-gov", and the "aws-iso*" partitions, +// but this deployment only ever runs in the standard "aws" partition, so a +// resource ARN whose partition field is anything else is rejected +const validPartition = "aws" + +var ( + errSyntax = iamerr.MalformedPolicyDocument("Syntax errors in policy.") + errMissingActions = iamerr.MalformedPolicyDocument("Policy statement must contain actions.") + errMissingResources = iamerr.MalformedPolicyDocument("Policy statement must contain resources.") + errPrincipalNotAllowed = iamerr.MalformedPolicyDocument("Policy document should not specify a principal.") + errDuplicateSid = iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.") + errMissingVendorPrefix = iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.") + errLegacyParsing = iamerr.MalformedPolicyDocument("The policy failed legacy parsing") +) + +// Validate checks raw against IAM's parameter-level constraints for a +// PolicyDocument value: a maximum length of 131072 and the allowed +// character set (tab/LF/CR plus printable Latin-1, U+0020-U+00FF, with at +// least one such character present — so an empty value is rejected here +// too, as a charset violation). +func Validate(field, raw string) error { + if len(raw) > MaxDocumentLength { + return iamerr.ValueTooLong(field, MaxDocumentLength) + } + if !isValidDocumentCharset(raw) { + return iamerr.InvalidCharset(field) + } + return nil +} + +func isValidDocumentCharset(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || r > 0xFF { + return false + } + } + return true +} + +// Parse parses raw as an IAM policy document and checks it against IAM +// policy grammar +func Parse(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errSyntax + } + return doc.Validate() +} + +// Validate checks d against IAM policy document grammar: a valid Version if +// present, a non-empty Statement (single object or array), document-wide +// unique Sids, and per statement, the rules enforced by Statement.Validate. +func (d Document) Validate() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errSyntax + } + if len(d.Statement) == 0 { + return errSyntax + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.Validate(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// Validate checks s against IAM policy statement grammar: a valid Effect, +// no Principal/NotPrincipal, an Action or NotAction (not both) with +// vendor-prefixed values, and a Resource or NotResource (not both) with +// ARN-shaped values. Condition is not modeled or validated. +func (s Statement) Validate() error { + switch s.Effect { + case "Allow", "Deny": + default: + return errSyntax + } + + if len(s.Principal) > 0 || len(s.NotPrincipal) > 0 { + return errPrincipalNotAllowed + } + + if len(s.Action) > 0 && len(s.NotAction) > 0 { + return errSyntax + } + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errMissingActions + } + for _, action := range s.Action { + if err := validateActionVendor(action); err != nil { + return err + } + } + for _, action := range s.NotAction { + if err := validateActionVendor(action); err != nil { + return err + } + } + + if len(s.Resource) > 0 && len(s.NotResource) > 0 { + return errSyntax + } + if len(s.Resource) == 0 && len(s.NotResource) == 0 { + return errMissingResources + } + for _, resource := range s.Resource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + for _, resource := range s.NotResource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + + return nil +} + +// validateActionVendor checks that action is either the bare wildcard "*" +// or has a syntactically valid "vendor:name" shape. The action name after +// the colon is not checked against any known service/action list — real +// IAM accepts unrecognized service/action names at this stage too. +func validateActionVendor(action string) error { + if action == "*" { + return nil + } + before, _, ok := strings.Cut(action, ":") + if !ok { + return errMissingVendorPrefix + } + vendor := before + if !vendorPattern.MatchString(vendor) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Vendor %s is not valid", vendor)) + } + return nil +} + +// validateResourceARN checks a single Resource/NotResource entry against +// IAM's ARN grammar: either the bare wildcard "*", or +// "arn:partition:service:region:account:resource". The service, region, +// account, and resource fields are not further validated — only the +// partition is checked, matching what real IAM enforces at this stage +func validateResourceARN(resource string) error { + if resource == "*" { + return nil + } + if !strings.Contains(resource, ":") { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Resource %s must be in ARN format or \"*\".", resource)) + } + + if strings.HasPrefix(resource, "arn:") { + fields := strings.SplitN(resource[len("arn:"):], ":", 5) + if len(fields) < 5 { + return errLegacyParsing + } + partition := fields[0] + if partition != validPartition { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, resource)) + } + return nil + } + + tokens := strings.SplitN(resource, ":", 6) + field := func(i int) string { + if i < len(tokens) { + return tokens[i] + } + return "*" + } + partition := field(1) + reconstructed := fmt.Sprintf("arn:%s:%s:%s:%s:%s", partition, field(2), field(3), field(4), field(5)) + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, reconstructed)) +} diff --git a/iamapi/policy/validate_test.go b/iamapi/policy/validate_test.go new file mode 100644 index 00000000..8019a6ee --- /dev/null +++ b/iamapi/policy/validate_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "errors" + "strings" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account. +func TestValidate(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means Validate must succeed + }{ + {"valid single statement", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid statement as single object, not array", `{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid bare wildcard action and resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`, nil}, + {"valid NotAction alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid NotResource alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"*"}]}`, nil}, + {"valid unrecognized vendor/action accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"totallyfakeservice:DoSomething","Resource":"*"}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil}, + {"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errSyntax}, + {"empty object", `{}`, errSyntax}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"missing statement", `{"Version":"2012-10-17"}`, errSyntax}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, errSyntax}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errSyntax}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, errSyntax}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, errSyntax}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, errSyntax}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, errSyntax}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, errMissingActions}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, errMissingResources}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, errMissingResources}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, errMissingVendorPrefix}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, errMissingVendorPrefix}, + + {"principal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + {"notprincipal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, errDuplicateSid}, + + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, errLegacyParsing}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + {"notresource with invalid shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"principal only, no action or resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/bob"}}]}`, errPrincipalNotAllowed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Parse(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Validate() = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateSize(t *testing.T) { + tests := []struct { + name string + raw string + wantErr error + }{ + {"valid small document", `{}`, nil}, + {"tab, newline, and carriage return allowed", "a\tb\nc\rd", nil}, + {"empty", "", iamerr.InvalidCharset("policyDocument")}, + {"exactly at max length", strings.Repeat("x", MaxDocumentLength), nil}, + {"one over max length", strings.Repeat("x", MaxDocumentLength+1), iamerr.ValueTooLong("policyDocument", MaxDocumentLength)}, + {"non-latin1 rune rejected", "emoji\U0001F600test", iamerr.InvalidCharset("policyDocument")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate("policyDocument", tt.raw) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateSize() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateSize() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ce6eb55c..4b4baa70 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -57,6 +57,11 @@ func (r *IAMApiRouter) Init() { "DeleteAccessKey": ctrl.DeleteAccessKey, "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, "ListAccessKeys": ctrl.ListAccessKeys, + // User Inline Policy CRUD + "PutUserPolicy": ctrl.PutUserPolicy, + "GetUserPolicy": ctrl.GetUserPolicy, + "DeleteUserPolicy": ctrl.DeleteUserPolicy, + "ListUserPolicies": ctrl.ListUserPolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 3c3b1692..7eef589a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -21,6 +21,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" @@ -113,6 +114,9 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { if !ok { return nil, iamerr.NoSuchEntityUser(username) } + if len(user.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -445,9 +449,163 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return out, nil } +func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInput) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return nil, iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Users[input.UserName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName string) (*types.PolicyEntry, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[userName] + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + user, ok := conf.Users[userName] + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + conf.Users[userName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) cloned.AccessKeys = slices.Clone(user.AccessKeys) + cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 7b8eaed9..d799d711 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -29,6 +29,10 @@ import ( // user may hold at once, matching the AWS IAM quota. const MaxAccessKeysPerUser = 2 +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// all of a single IAM user's inline policy documents combined +const MaxInlinePolicyBytesPerUser = 2048 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -86,6 +90,24 @@ type GetAccessKeyLastUsedOutput struct { Region string } +type PutUserPolicyInput struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserPoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -99,6 +121,11 @@ type Storer interface { DeleteAccessKey(ctx context.Context, username, accessKeyID string) error GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) + + PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error + GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) + DeleteUserPolicy(ctx context.Context, userName, policyName string) error + ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c661db9..48399724 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -233,6 +233,9 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if err != nil { return err } + if len(user.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -553,6 +556,122 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp return out, nil } +func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) { + user, err := s.GetUser(ctx, userName) + if err != nil { + return nil, err + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error { + user, err := s.GetUser(ctx, userName) + if err != nil { + return err + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + // deleteByPath permanently removes a secret and all its versions without // checking for existence first. func (s *VaultStore) deleteByPath(username string) error { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go new file mode 100644 index 00000000..a7149e81 --- /dev/null +++ b/iamapi/types/policy.go @@ -0,0 +1,96 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +// Policies holds every kind of policy attached to an identity (user, role ...) +// Inline is the only populated field for now +type Policies struct { + Inline []PolicyEntry `json:"inline,omitempty"` +} + +// PolicyEntry is the storage representation of a single inline policy. It +// round-trips through JSON for the internal and Vault storers and is +// never marshaled to XML directly — mirrors AccessKeyEntry. PolicyDocument +// holds the exact bytes submitted by the caller (after validation), not a +// re-serialized form +type PolicyEntry struct { + PolicyName string + PolicyDocument string + CreateDate time.Time + UpdateDate time.Time +} + +type PutUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"` + Result GetUserPolicyResult `xml:"GetUserPolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +// GetUserPolicyResult's PolicyDocument must be RFC 3986 percent-encoded by +// the caller before assignment — see iamutil.EncodePolicyDocument. Real +// IAM returns PolicyDocument URL-encoded; xml.Marshal does not do this +// encoding on its own. +type GetUserPolicyResult struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"` + Result ListUserPoliciesResult `xml:"ListUserPoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListUserPoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserPoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type PolicyNameList struct { + Members []string `xml:"member"` +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index 9083ea2b..f8f4a91f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -106,6 +106,7 @@ type User struct { CreateDate time.Time `xml:"CreateDate"` Tags []Tag `xml:"Tags>member,omitempty"` AccessKeys []AccessKeyEntry `xml:"-"` + Policies Policies `xml:"-"` } type Tag struct { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index ac91884e..8186572e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1233,6 +1233,47 @@ func TestIAMListAccessKeys(ts *TestState) { ts.Run(IAMListAccessKeys_pagination) } +func TestIAMPutUserPolicy(ts *TestState) { + ts.Run(IAMPutUserPolicy_missing_user_name) + ts.Run(IAMPutUserPolicy_missing_policy_name) + ts.Run(IAMPutUserPolicy_missing_policy_document) + ts.Run(IAMPutUserPolicy_invalid_policy_name) + ts.Run(IAMPutUserPolicy_long_policy_name) + ts.Run(IAMPutUserPolicy_non_ascii_policy_document) + ts.Run(IAMPutUserPolicy_non_existing_user) + ts.Run(IAMPutUserPolicy_malformed_policy_document) + ts.Run(IAMPutUserPolicy_principal_not_allowed) + ts.Run(IAMPutUserPolicy_limit_exceeded) + ts.Run(IAMPutUserPolicy_success) + ts.Run(IAMPutUserPolicy_overwrite_updates_existing) +} + +func TestIAMGetUserPolicy(ts *TestState) { + ts.Run(IAMGetUserPolicy_missing_user_name) + ts.Run(IAMGetUserPolicy_missing_policy_name) + ts.Run(IAMGetUserPolicy_non_existing_user) + ts.Run(IAMGetUserPolicy_non_existing_policy) + ts.Run(IAMGetUserPolicy_success) +} + +func TestIAMDeleteUserPolicy(ts *TestState) { + ts.Run(IAMDeleteUserPolicy_missing_user_name) + ts.Run(IAMDeleteUserPolicy_missing_policy_name) + ts.Run(IAMDeleteUserPolicy_non_existing_user) + ts.Run(IAMDeleteUserPolicy_non_existing_policy) + ts.Run(IAMDeleteUserPolicy_success) + ts.Run(IAMDeleteUserPolicy_blocks_user_deletion) +} + +func TestIAMListUserPolicies(ts *TestState) { + ts.Run(IAMListUserPolicies_missing_user_name) + ts.Run(IAMListUserPolicies_non_existing_user) + ts.Run(IAMListUserPolicies_invalid_max_items) + ts.Run(IAMListUserPolicies_empty_result) + ts.Run(IAMListUserPolicies_success) + ts.Run(IAMListUserPolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1246,6 +1287,10 @@ func TestIAM(ts *TestState) { TestIAMDeleteAccessKey(ts) TestIAMGetAccessKeyLastUsed(ts) TestIAMListAccessKeys(ts) + TestIAMPutUserPolicy(ts) + TestIAMGetUserPolicy(ts) + TestIAMDeleteUserPolicy(ts) + TestIAMListUserPolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1674,6 +1719,35 @@ func GetIntTests() IntTests { "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, "IAMListAccessKeys_success": IAMListAccessKeys_success, "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, + "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, + "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, + "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, + "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, + "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, + "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, + "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, + "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, + "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, + "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, + "IAMPutUserPolicy_success": IAMPutUserPolicy_success, + "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, + "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, + "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, + "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, + "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, + "IAMGetUserPolicy_success": IAMGetUserPolicy_success, + "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, + "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, + "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, + "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, + "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, + "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, + "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, + "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, + "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, + "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, + "IAMListUserPolicies_success": IAMListUserPolicies_success, + "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_delete_user_policy.go b/tests/integration/iam_delete_user_policy.go new file mode 100644 index 00000000..cf4ac726 --- /dev/null +++ b/tests/integration/iam_delete_user_policy.go @@ -0,0 +1,203 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMDeleteUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMDeleteUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMDeleteUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_success(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteUserPolicy response request id") + } + + _, err = getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUserPolicy(userName, "p")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_blocks_user_deletion(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_blocks_user_deletion" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMUserPolicy(client, userName, "p") + deleteUserErr := deleteIAMUser(client, userName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteUserErr + }) +} + +func deleteIAMUserPolicyRaw(client *iam.Client, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteUserPolicy(ctx, input) +} + +func deleteIAMUserPolicy(client *iam.Client, userName, policyName string) error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: &policyName}) + return err +} + +// deleteIAMUserAndPolicies deletes all of the user's inline policies before +// deleting the user, since DeleteUser rejects users with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMUserAndPolicies(client *iam.Client, userName string) error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMUserPolicy(client, userName, policyName); err != nil { + return err + } + } + return deleteIAMUser(client, userName) +} diff --git a/tests/integration/iam_get_user_policy.go b/tests/integration/iam_get_user_policy.go new file mode 100644 index 00000000..b67adb64 --- /dev/null +++ b/tests/integration/iam_get_user_policy.go @@ -0,0 +1,165 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMGetUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMGetUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMGetUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetUserPolicy_success(s *S3Conf) error { + testName := "IAMGetUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetUserPolicy output") + } + if aws.ToString(out.UserName) != userName { + return fmt.Errorf("expected user name %q, instead got %q", userName, aws.ToString(out.UserName)) + } + if aws.ToString(out.PolicyName) != "ReadOnly" { + return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetUserPolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMUserPolicy(client *iam.Client, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetUserPolicy(ctx, input) +} diff --git a/tests/integration/iam_list_user_policies.go b/tests/integration/iam_list_user_policies.go new file mode 100644 index 00000000..c9514ef2 --- /dev/null +++ b/tests/integration/iam_list_user_policies.go @@ -0,0 +1,223 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "slices" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListUserPolicies_missing_user_name(s *S3Conf) error { + testName := "IAMListUserPolicies_missing_user_name" + body := []byte("Action=ListUserPolicies&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMListUserPolicies_non_existing_user(s *S3Conf) error { + testName := "IAMListUserPolicies_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListUserPolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListUserPolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_empty_result(s *S3Conf) error { + testName := "IAMListUserPolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if len(out.PolicyNames) != 0 { + return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_success(s *S3Conf) error { + testName := "IAMListUserPolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListUserPolicies response request id") + } + got := slices.Clone(out.PolicyNames) + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_pagination(s *S3Conf) error { + testName := "IAMListUserPolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1)} + var pages []*iam.ListUserPoliciesOutput + for { + out, err := listIAMUserPolicies(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.PolicyNames) != 1 { + return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, page.PolicyNames...) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMUserPolicies(client *iam.Client, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListUserPolicies(ctx, input) +} diff --git a/tests/integration/iam_put_user_policy.go b/tests/integration/iam_put_user_policy.go new file mode 100644 index 00000000..e47e90a3 --- /dev/null +++ b/tests/integration/iam_put_user_policy.go @@ -0,0 +1,376 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +const validIAMPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + +func IAMPutUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + "PolicyDocument": {validIAMPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMPutUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyDocument": {validIAMPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMPutUserPolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMPutUserPolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutUserPolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutUserPolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMPutUserPolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_malformed_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + cases := []struct { + name string + doc string + wantErr iamerr.APIError + }{ + {"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")}, + } + + for _, c := range cases { + if err := func() error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(c.doc), + }) + if err := checkIAMApiErr(err, c.wantErr); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutUserPolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutUserPolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutUserPolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("user", userName, storage.MaxInlinePolicyBytesPerUser)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_success(s *S3Conf) error { + testName := "IAMPutUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutUserPolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutUserPolicy response request id") + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutUserPolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}` + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != updated { + return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMUserPolicy(client *iam.Client, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutUserPolicy(ctx, input) +} From e6573e11a920c105ada977793b0c4d03f607aab3 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 14 Jul 2026 22:24:01 +0400 Subject: [PATCH 3/7] feat: add IAM Role CRUD Adds `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole`, and `UpdateAssumeRolePolicy` to the standalone IAM service, following the same controller/storage patterns established for users. Both the internal filesystem/S3-backed store and the Vault-backed store implement the new `Storer` methods, with role-specific indexing and lookup helpers mirroring the existing user ones. Role creation requires a trust policy, passed as `AssumeRolePolicyDocument`. A trust policy is a distinct kind of IAM policy document that governs who (or what) is allowed to assume a role, rather than what actions the role itself is permitted to perform. Its grammar is effectively the inverse of an identity policy: `Principal` is required, `Action`/`NotAction` values must carry the `sts:` prefix, and `Resource`/`NotResource` are forbidden. This is implemented in `iamapi/policy/trust.go` as a new validation path alongside the existing identity-policy validation, and is reused by `UpdateAssumeRolePolicy` when replacing a role's trust policy. Also fixes user name uniqueness enforcement to be case-insensitive, matching AWS IAM behavior, and applies the same case-insensitive handling to role names. The internal store now maintains lowercase name indexes for both users and roles, and the Vault store resolves the canonical stored key via a case-insensitive list-and-compare fallback since Vault's KV paths are case-sensitive. --- iamapi/controller.go | 196 ++++++++ iamapi/controller_test.go | 473 ++++++++++++++++++ iamapi/iamerr/errors.go | 30 +- iamapi/internal/iamutil/user.go | 86 ++++ iamapi/policy/document.go | 4 + iamapi/policy/trust.go | 212 ++++++++ iamapi/policy/trust_test.go | 92 ++++ iamapi/router.go | 6 + iamapi/storage/internal.go | 271 +++++++++- iamapi/storage/storer.go | 24 + iamapi/storage/storer_test.go | 163 ++++++ iamapi/storage/vault.go | 326 +++++++++++- iamapi/types/role.go | 113 +++++ runiamtests.sh | 2 +- tests/integration/group-tests.go | 116 +++++ tests/integration/iam_create_role.go | 433 ++++++++++++++++ tests/integration/iam_create_user.go | 21 + tests/integration/iam_delete_role.go | 95 ++++ tests/integration/iam_get_role.go | 122 +++++ tests/integration/iam_list_roles.go | 375 ++++++++++++++ .../iam_update_assume_role_policy.go | 253 ++++++++++ tests/integration/utils.go | 54 ++ 22 files changed, 3434 insertions(+), 33 deletions(-) create mode 100644 iamapi/policy/trust.go create mode 100644 iamapi/policy/trust_test.go create mode 100644 iamapi/types/role.go create mode 100644 tests/integration/iam_create_role.go create mode 100644 tests/integration/iam_delete_role.go create mode 100644 tests/integration/iam_get_role.go create mode 100644 tests/integration/iam_list_roles.go create mode 100644 tests/integration/iam_update_assume_role_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index ef4b5086..f224d24b 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -522,3 +522,199 @@ func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxUserNameLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + if err := iamutil.ValidatePath("path", path); err != nil { + return nil, err + } + + assumeRolePolicyDocument, ok := iamutil.RequestParam(ctx, "AssumeRolePolicyDocument") + if !ok || assumeRolePolicyDocument == "" { + debuglogger.Logf("missing required CreateRole parameter: AssumeRolePolicyDocument") + return nil, iamerr.MissingValue("assumeRolePolicyDocument") + } + if err := policy.Validate("assumeRolePolicyDocument", assumeRolePolicyDocument); err != nil { + return nil, err + } + if err := policy.ParseTrust(assumeRolePolicyDocument); err != nil { + return nil, err + } + if len(assumeRolePolicyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + description, _ := iamutil.RequestParam(ctx, "Description") + if err := iamutil.ValidateDescription("description", description); err != nil { + return nil, err + } + + maxSessionDuration, err := iamutil.ParseMaxSessionDuration(ctx) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + for range 3 { + roleID, err := iamutil.GenerateRoleID() + if err != nil { + return nil, err + } + + role := types.Role{ + Path: path, + RoleName: roleName, + RoleID: roleID, + Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName), + CreateDate: time.Now().UTC().Truncate(time.Second), + AssumeRolePolicyDocument: assumeRolePolicyDocument, + Description: description, + MaxSessionDuration: maxSessionDuration, + Tags: tags, + } + + stored, err := c.store.CreateRole(ctx.Context(), role) + if errors.Is(err, storage.ErrRoleIDAlreadyExists) { + debuglogger.Logf("IAM role ID collision while creating role %q: %v", roleName, err) + continue + } + if err != nil { + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err + } + + stored.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(stored.AssumeRolePolicyDocument) + + return &Response{Data: &types.CreateRoleResponse{ + Result: types.CreateRoleResult{Role: stored}, + }}, nil + } + + err = fmt.Errorf("generate IAM role id: exhausted collision retries") + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err +} + +func (c IAMApiController) GetRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + role, err := c.store.GetRole(ctx.Context(), roleName) + if err != nil { + debuglogger.Logf("failed to get IAM role %q: %v", roleName, err) + return nil, err + } + + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + + return &Response{Data: &types.GetRoleResponse{ + Result: types.GetRoleResult{Role: role}, + }}, nil +} + +func (c IAMApiController) ListRoles(ctx fiber.Ctx) (*Response, error) { + pathPrefix, ok := iamutil.RequestParam(ctx, "PathPrefix") + if !ok || pathPrefix == "" { + pathPrefix = iamutil.DefaultUserPath + } + if err := iamutil.ValidatePathPrefix(pathPrefix); err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRoles") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRoles(ctx.Context(), storage.ListRolesInput{ + PathPrefix: pathPrefix, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM roles: %v", err) + return nil, err + } + + roles := make([]types.Role, len(out.Roles)) + for i, role := range out.Roles { + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + roles[i] = role + } + + return &Response{Data: &types.ListRolesResponse{ + Result: types.ListRolesResult{ + Roles: types.Roles{Members: roles}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + +func (c IAMApiController) DeleteRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to delete IAM role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRoleResponse{}}, nil +} + +func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required UpdateAssumeRolePolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + // Confirm the role exists before inspecting policy document content + if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to get IAM role %q for UpdateAssumeRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.ParseTrust(policyDocument); err != nil { + return nil, err + } + if len(policyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + if _, err := c.store.UpdateAssumeRolePolicy(ctx.Context(), storage.UpdateAssumeRolePolicyInput{ + RoleName: roleName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to update IAM assume role policy for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 58cf4db8..dd975302 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -30,6 +30,7 @@ import ( ) var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`) +var roleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) func TestIAMApiControllerUserLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -828,6 +829,478 @@ func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") } +const validTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +func TestIAMApiControllerRoleLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"/engineering/"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"a test role"}, + "MaxSessionDuration": {"7200"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateRoleResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateRoleResponse" { + t.Fatalf("CreateRole XMLName = %#v", createOut.XMLName) + } + role := createOut.Result.Role + if role.Path != "/engineering/" || role.RoleName != "my-role" { + t.Fatalf("created role = %#v, want path/name", role) + } + if !roleIDPattern.MatchString(role.RoleID) { + t.Fatalf("RoleId = %q, want AWS IAM role id form", role.RoleID) + } + if role.Arn != "arn:aws:iam::000000000000:role/engineering/my-role" { + t.Fatalf("Arn = %q", role.Arn) + } + if role.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + if role.Description != "a test role" { + t.Fatalf("Description = %q", role.Description) + } + if role.MaxSessionDuration != 7200 { + t.Fatalf("MaxSessionDuration = %d, want 7200", role.MaxSessionDuration) + } + wantEncodedPolicy := iamutil.EncodePolicyDocument(validTrustPolicy) + if role.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("AssumeRolePolicyDocument = %q, want %q", role.AssumeRolePolicyDocument, wantEncodedPolicy) + } + if role.RoleLastUsed == nil { + t.Fatal("CreateRole RoleLastUsed = nil, want non-nil empty element") + } + if len(role.Tags) != 1 || role.Tags[0].Key != "env" || role.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", role.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateRole missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"MY-ROLE"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", "Role with name MY-ROLE already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRole status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, get), &getOut) + gotRole := getOut.Result.Role + if gotRole.RoleID != role.RoleID || !gotRole.CreateDate.Equal(role.CreateDate) { + t.Fatalf("GetRole identity = %#v, want RoleId/CreateDate preserved from %#v", gotRole, role) + } + if gotRole.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + if gotRole.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("GetRole AssumeRolePolicyDocument = %q, want %q", gotRole.AssumeRolePolicyDocument, wantEncodedPolicy) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRoles"}, + "PathPrefix": {"/engineering/"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRoles status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.Roles.Members) != 1 || listOut.Result.Roles.Members[0].RoleName != "my-role" { + t.Fatalf("ListRoles = %#v, want my-role", listOut.Result.Roles.Members) + } + if listOut.Result.Roles.Members[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", listOut.Result.Roles.Members[0].RoleLastUsed) + } + if listOut.Result.Roles.Members[0].AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("ListRoles AssumeRolePolicyDocument = %q, want %q", listOut.Result.Roles.Members[0].AssumeRolePolicyDocument, wantEncodedPolicy) + } + + const updatedTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + update := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {updatedTrustPolicy}, + }) + if update.StatusCode != http.StatusOK { + t.Fatalf("UpdateAssumeRolePolicy status = %d, body=%s", update.StatusCode, readBody(t, update)) + } + var updateOut iamtypes.UpdateAssumeRolePolicyResponse + unmarshalXML(t, readBody(t, update), &updateOut) + if updateOut.XMLName.Local != "UpdateAssumeRolePolicyResponse" || updateOut.ResponseMetadata.RequestID == "" { + t.Fatalf("UpdateAssumeRolePolicy output = %#v", updateOut) + } + + oversizedTrustPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + updateOversized := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {oversizedTrustPolicy}, + }) + requireIAMError(t, updateOversized, http.StatusConflict, "Sender", "LimitExceeded", "Cannot exceed quota for ACLSizePerRole: 2048") + + getAfterUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + var getAfterUpdateOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, getAfterUpdate), &getAfterUpdateOut) + wantUpdatedEncoded := iamutil.EncodePolicyDocument(updatedTrustPolicy) + if getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument != wantUpdatedEncoded { + t.Fatalf("GetRole after update AssumeRolePolicyDocument = %q, want %q", getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument, wantUpdatedEncoded) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"my-role"}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + var deleteOut iamtypes.DeleteRoleResponse + unmarshalXML(t, readBody(t, deleteResp), &deleteOut) + if deleteOut.XMLName.Local != "DeleteRoleResponse" || deleteOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRole output = %#v", deleteOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role with name my-role cannot be found.") +} + +func TestIAMApiControllerCreateRoleValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing role name", + params: url.Values{ + "Action": {"CreateRole"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"bad/name"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for roleName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "long role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {strings.Repeat("a", 65)}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must have length less than or equal to 64", + }, + { + name: "invalid path", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"bad"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.", + }, + { + name: "missing assume role policy document", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid json policy", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {"{invalid"}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "This policy contains invalid Json", + }, + { + name: "policy statement empty", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Could not parse the policy: Statement is empty!", + }, + { + name: "policy missing principal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal", + }, + { + name: "policy principal empty object", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal cannot be empty!", + }, + { + name: "policy action not sts prefixed", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy may only specify STS AssumeRole actions.", + }, + { + name: "policy has resource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Has prohibited field Resource", + }, + { + name: "policy has notresource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy must not contain resources.", + }, + { + name: "policy allow with notprincipal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Allow with NotPrincipal is not allowed.", + }, + { + name: "policy too large", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {strings.Repeat("x", 131073)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072", + }, + { + name: "description invalid charset", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"emoji\U0001F600test"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'description' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", + }, + { + name: "trust policy exceeds ACLSizePerRole quota", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Cannot exceed quota for ACLSizePerRole: 2048", + }, + { + name: "max session duration not a number", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"not-a-number"}, + }, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "max session duration too low", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"3599"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600", + }, + { + name: "max session duration too high", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"43201"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200", + }, + { + name: "duplicate tag key", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Tags.member.1.Key": {"dup"}, + "Tags.member.1.Value": {"one"}, + "Tags.member.2.Key": {"DUP"}, + "Tags.member.2.Value": {"two"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMActionPost(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "get missing role name", + params: url.Values{ + "Action": {"GetRole"}, + }, + status: http.StatusBadRequest, + code: "MissingParameter", + message: "The request must contain the parameter RoleName.", + }, + { + name: "get missing role", + params: url.Values{ + "Action": {"GetRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "delete missing role", + params: url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing role", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + "PolicyDocument": {validTrustPolicy}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing document", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 5c55c6dd..4c919a0e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -113,7 +113,7 @@ func (e Error) XMLBody(requestID string) []byte { type errorXML struct { Type ErrorType Code string - Message string + Message string `xml:",omitempty"` } var errorCodeResponse = map[ErrorCode]Error{ @@ -345,10 +345,22 @@ func NoSuchEntityAccessKey(accessKeyID string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The Access Key with id %s cannot be found", accessKeyID), http.StatusNotFound) } +func EntityAlreadyExistsRole(roleName string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Role with name %s already exists.", roleName), http.StatusConflict) +} + +func NoSuchEntityRole(roleName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role with name %s cannot be found.", roleName), http.StatusNotFound) +} + func AccessKeysLimitExceeded(maxKeys int) Error { return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for AccessKeysPerUser: %d", maxKeys), http.StatusConflict) } +func TrustPolicySizeLimitExceeded(maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ACLSizePerRole: %d", maxBytes), http.StatusConflict) +} + func ValidationError(message string) Error { return newSenderError("ValidationError", message, http.StatusBadRequest) } @@ -417,6 +429,22 @@ func InvalidCharset(field string) Error { return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) } +func InvalidDescriptionCharset(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", field)) +} + +func MaxSessionDurationTooLow() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600") +} + +func MaxSessionDurationTooHigh() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200") +} + +func MalformedInput() Error { + return newSenderError("MalformedInput", "", http.StatusBadRequest) +} + func MalformedPolicyDocument(message string) Error { return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) } diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 68bcb667..24a91189 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -41,6 +41,15 @@ const ( userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" maxTagKeyLen = 128 maxTagValLen = 256 + + roleIDPrefix = "AROA" + roleIDRandomLen = 17 + + MaxRoleDescriptionLen = 1000 + + DefaultMaxSessionDuration = 3600 + MinMaxSessionDuration = 3600 + MaxMaxSessionDuration = 43200 ) var ( @@ -82,6 +91,68 @@ func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) return userName, nil } +// GetRoleName resolves the RoleName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +func GetRoleName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + roleName, ok := RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + debuglogger.Logf("missing required %s parameter: RoleName", operation) + return "", missingErr + } + if err := ValidateName("roleName", roleName, maxLen); err != nil { + return "", err + } + + return roleName, nil +} + +// ParseMaxSessionDuration reads the MaxSessionDuration request parameter, +// defaulting to DefaultMaxSessionDuration when absent, and validates it +// falls within [MinMaxSessionDuration, MaxMaxSessionDuration]. +func ParseMaxSessionDuration(ctx fiber.Ctx) (int32, error) { + raw, ok := RequestParam(ctx, "MaxSessionDuration") + if !ok || raw == "" { + return DefaultMaxSessionDuration, nil + } + + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + debuglogger.Logf("malformed MaxSessionDuration value %q", raw) + return 0, iamerr.MalformedInput() + } + if parsed < MinMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooLow() + } + if parsed > MaxMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooHigh() + } + + return int32(parsed), nil +} + +// ValidateDescription checks that the IAM role "Description" fits +// within MaxRoleDescriptionLen and uses the allowed charset — printable +// Latin-1 (excluding 0x7F-0xA0) plus tab/LF/CR +func ValidateDescription(field, desc string) error { + if len(desc) > MaxRoleDescriptionLen { + debuglogger.Logf("IAM role description exceeds maximum length: field=%s length=%d max=%d", field, len(desc), MaxRoleDescriptionLen) + return iamerr.ValueTooLong(field, MaxRoleDescriptionLen) + } + for _, r := range desc { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || (r > 0x7E && r < 0xA1) || r > 0xFF { + debuglogger.Logf("invalid IAM role description charset: field=%s", field) + return iamerr.InvalidDescriptionCharset(field) + } + } + return nil +} + // ParseMaxItems reads the MaxItems request parameter, defaulting to // DefaultMaxItems when absent. operation is included in the debug log on // parse failure (e.g. "ListUsers", "ListAccessKeys"). @@ -198,6 +269,21 @@ func GenerateUserID() (string, error) { return id, nil } +// BuildRoleArn constructs the ARN for an IAM role. +func BuildRoleArn(accountID, path, roleName string) string { + return fmt.Sprintf("arn:aws:iam::%s:role%s%s", accountID, path, roleName) +} + +// GenerateRoleID returns a new cryptographically random IAM role ID in the AROA… format. +func GenerateRoleID() (string, error) { + id, err := generateAWSID(roleIDPrefix, roleIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM role ID: %v", err) + return "", err + } + return id, nil +} + // generateAWSID builds an AWS-style unique identifier: a fixed prefix // followed by randomLen characters drawn from userIDAlphabet. func generateAWSID(prefix string, randomLen int) (string, error) { diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go index 39a7bc04..50490de2 100644 --- a/iamapi/policy/document.go +++ b/iamapi/policy/document.go @@ -41,6 +41,10 @@ type Statement struct { NotResource StringOrSlice Principal json.RawMessage NotPrincipal json.RawMessage + // Condition is never structurally validated (neither the identity- nor + // trust-policy path models its grammar) — it is only checked for + // presence, by the trust-policy Cognito-provider rule. + Condition json.RawMessage } // UnmarshalJSON accepts Statement as either a single JSON object or an diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go new file mode 100644 index 00000000..c86380f2 --- /dev/null +++ b/iamapi/policy/trust.go @@ -0,0 +1,212 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// trustPrincipalKeys are the only keys IAM accepts inside a trust policy +// statement's Principal object. CanonicalUser is deliberately not accepted +// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical +// user id which is the legacy s3 user identifier and is not planned to support +var trustPrincipalKeys = map[string]bool{ + "AWS": true, + "Service": true, + "Federated": true, +} + +const cognitoFederatedProvider = "cognito-identity.amazonaws.com" + +// validServicePrincipals are the only Service principal values the gateway +// recognizes. Real AWS validates Service against its live catalog of +// ~300+ service principals; the gateway only exposes S3, STS, and IAM +// APIs, so those are the only services that could plausibly ever assume a +// role here. +var validServicePrincipals = map[string]bool{ + "s3.amazonaws.com": true, + "sts.amazonaws.com": true, + "iam.amazonaws.com": true, +} + +// MaxTrustPolicyBytes is IAM's ACLSizePerRole quota: a role has exactly one +// trust policy, so unlike inline identity policies (which sum across all of +// a user's/role's named policies) this is a plain length check against the +// single AssumeRolePolicyDocument/PolicyDocument value. +const MaxTrustPolicyBytes = 2048 + +var ( + errTrustInvalidJSON = iamerr.MalformedPolicyDocument("This policy contains invalid Json") + errTrustInvalidVersion = iamerr.MalformedPolicyDocument("The policy must contain a valid version string") + errTrustEmptyStatement = iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!") + errTrustDuplicateSid = iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique") + errTrustMissingEffect = iamerr.MalformedPolicyDocument("Missing required field Effect") + errTrustMissingPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal") + errTrustEmptyPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!") + errTrustPrincipalNotObject = iamerr.MalformedPolicyDocument("Principal must be a JSON object.") + errTrustAllowNotPrincipal = iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.") + errTrustNotPrincipalForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.") + errTrustMissingAction = iamerr.MalformedPolicyDocument("Missing required field Action") + errTrustNonSTSAction = iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.") + errTrustResourceForbidden = iamerr.MalformedPolicyDocument("Has prohibited field Resource") + errTrustNotResourceForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.") + errTrustCognitoConditionRequired = iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider") + errTrustSyntax = iamerr.MalformedPolicyDocument("Syntax error in policy.") +) + +// ParseTrust parses raw as an IAM role trust-policy document (the value of +// AssumeRolePolicyDocument / UpdateAssumeRolePolicy's PolicyDocument) and +// checks it against trust-policy grammar: Principal is required (the +// opposite of an identity policy), Action/NotAction values must carry the +// "sts:" prefix, and Resource/NotResource are forbidden. +func ParseTrust(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errTrustInvalidJSON + } + return doc.ValidateTrust() +} + +// ValidateTrust checks d against IAM's trust-policy document grammar: a +// valid Version if present, a non-empty Statement (single object or +// array), document-wide unique Sids, and per statement, the rules enforced +// by Statement.ValidateTrust. +func (d Document) ValidateTrust() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errTrustInvalidVersion + } + if len(d.Statement) == 0 { + return errTrustEmptyStatement + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.ValidateTrust(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errTrustDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// ValidateTrust checks s against IAM trust-policy statement grammar: a +// valid Effect, a required Principal (never NotPrincipal), an Action or +// NotAction with only "sts:"-prefixed values, and no Resource/NotResource. +// Condition is not modeled or validated(not supported at the moment) +func (s Statement) ValidateTrust() error { + switch s.Effect { + case "Allow", "Deny": + case "": + return errTrustMissingEffect + default: + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid effect: %s", s.Effect)) + } + + if len(s.NotPrincipal) > 0 { + if s.Effect == "Allow" { + return errTrustAllowNotPrincipal + } + return errTrustNotPrincipalForbidden + } + if err := s.validateTrustPrincipal(); err != nil { + return err + } + + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errTrustMissingAction + } + for _, action := range s.Action { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + for _, action := range s.NotAction { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + + if len(s.Resource) > 0 { + return errTrustResourceForbidden + } + if len(s.NotResource) > 0 { + return errTrustNotResourceForbidden + } + + return nil +} + +// validateTrustPrincipal checks s.Principal against trust-policy grammar: +// required, a JSON object (not a bare string or array), non-empty, with +// only AWS/Service/Federated keys, plus the Cognito-specific Condition +// requirement. Real AWS additionally validates that AWS/Service values +// resolve to real accounts/services against its live catalog; the gateway +// has no such catalog for AWS account/ARN values and validates those shape +// only. Service values are the exception — they're checked against +// validServicePrincipals, since the gateway only exposes S3, STS, and IAM +// APIs and so only those services could ever assume a role here. +func (s Statement) validateTrustPrincipal() error { + raw := s.Principal + if len(raw) == 0 { + return errTrustMissingPrincipal + } + + var principal map[string]StringOrSlice + if err := json.Unmarshal(raw, &principal); err != nil { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return errTrustPrincipalNotObject + } + return errTrustSyntax + } + + if len(principal) == 0 { + return errTrustEmptyPrincipal + } + + requiresCondition := false + for key, values := range principal { + if !trustPrincipalKeys[key] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key)) + } + if key == "Service" { + for _, v := range values { + if !validServicePrincipals[v] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q:%q", strings.ToUpper(key), v)) + } + } + } + if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) { + requiresCondition = true + } + } + + if requiresCondition && len(s.Condition) == 0 { + return errTrustCognitoConditionRequired + } + + return nil +} diff --git a/iamapi/policy/trust_test.go b/iamapi/policy/trust_test.go new file mode 100644 index 00000000..ecc51cf6 --- /dev/null +++ b/iamapi/policy/trust_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "errors" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account, except +// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md). +// The "ec2 service (unsupported)" case is one such deliberate deviation: +// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, +// STS, and IAM APIs, so it restricts Service principals to those three. +func TestParseTrust(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means ParseTrust must succeed + }{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errTrustInvalidJSON}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustInvalidVersion}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errTrustEmptyStatement}, + {"missing statement", `{"Version":"2012-10-17"}`, errTrustEmptyStatement}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustMissingEffect}, + + {"missing principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, errTrustMissingPrincipal}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, errTrustEmptyPrincipal}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, errTrustPrincipalNotObject}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, errTrustSyntax}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (unsupported)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustAllowNotPrincipal}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction}, + {"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, errTrustResourceForbidden}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, errTrustNotResourceForbidden}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustDuplicateSid}, + + {"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ParseTrust(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ParseTrust() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ParseTrust() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index 4b4baa70..4e6444e9 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -62,6 +62,12 @@ func (r *IAMApiRouter) Init() { "GetUserPolicy": ctrl.GetUserPolicy, "DeleteUserPolicy": ctrl.DeleteUserPolicy, "ListUserPolicies": ctrl.ListUserPolicies, + // Role CRUD + "CreateRole": ctrl.CreateRole, + "GetRole": ctrl.GetRole, + "ListRoles": ctrl.ListRoles, + "DeleteRole": ctrl.DeleteRole, + "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 7eef589a..81a76a61 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -54,12 +54,24 @@ type iamConfig struct { // AccessKeyIndex maps an access key id to the username that owns it, // so GetAccessKeyLastUsed can resolve a key without scanning every user. AccessKeyIndex map[string]string `json:"accessKeyIndex"` + // UserNameIndex maps a lowercased user name to the canonical (as-created) + // stored user name, so lookups can enforce AWS's case-insensitive + // uniqueness while still preserving the original casing in conf.Users's + // key and the stored User.UserName. + UserNameIndex map[string]string `json:"userNameIndex"` + + Roles map[string]types.Role `json:"roles"` + // RoleNameIndex is UserNameIndex's counterpart for roles. + RoleNameIndex map[string]string `json:"roleNameIndex"` } func defaultIAMConfig() iamConfig { return iamConfig{ Users: map[string]types.User{}, AccessKeyIndex: map[string]string{}, + UserNameIndex: map[string]string{}, + Roles: map[string]types.Role{}, + RoleNameIndex: map[string]string{}, } } @@ -70,6 +82,49 @@ func normalizeIAMConfig(conf *iamConfig) { if conf.AccessKeyIndex == nil { conf.AccessKeyIndex = make(map[string]string) } + if conf.UserNameIndex == nil { + conf.UserNameIndex = make(map[string]string) + } + for name := range conf.Users { + key := strings.ToLower(name) + if _, ok := conf.UserNameIndex[key]; !ok { + conf.UserNameIndex[key] = name + } + } + + if conf.Roles == nil { + conf.Roles = make(map[string]types.Role) + } + if conf.RoleNameIndex == nil { + conf.RoleNameIndex = make(map[string]string) + } + for name := range conf.Roles { + key := strings.ToLower(name) + if _, ok := conf.RoleNameIndex[key]; !ok { + conf.RoleNameIndex[key] = name + } + } +} + +// lookupUser resolves name to the canonical stored user name and entry, +// case-insensitively, via conf.UserNameIndex. +func lookupUser(conf iamConfig, name string) (string, types.User, bool) { + canonical, ok := conf.UserNameIndex[strings.ToLower(name)] + if !ok { + return "", types.User{}, false + } + user, ok := conf.Users[canonical] + return canonical, user, ok +} + +// lookupRole is lookupUser's counterpart for roles. +func lookupRole(conf iamConfig, name string) (string, types.Role, bool) { + canonical, ok := conf.RoleNameIndex[strings.ToLower(name)] + if !ok { + return "", types.Role{}, false + } + role, ok := conf.Roles[canonical] + return canonical, role, ok } func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { @@ -82,7 +137,8 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U return nil, err } - if _, ok := conf.Users[user.UserName]; ok { + key := strings.ToLower(user.UserName) + if _, ok := conf.UserNameIndex[key]; ok { return nil, iamerr.EntityAlreadyExistsUser(user.UserName) } for _, existing := range conf.Users { @@ -92,6 +148,7 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U } conf.Users[user.UserName] = user + conf.UserNameIndex[key] = user.UserName return json.Marshal(conf) }); err != nil { return nil, unwrapAPIError(err) @@ -110,7 +167,7 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -121,7 +178,8 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - delete(conf.Users, username) + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) return json.Marshal(conf) }) return unwrapAPIError(err) @@ -136,7 +194,7 @@ func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User return nil, err } - user, ok := conf.Users[username] + _, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -204,7 +262,7 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -213,8 +271,8 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { - if _, ok := conf.Users[finalName]; ok { + if !strings.EqualFold(finalName, canonical) { + if _, ok := conf.UserNameIndex[strings.ToLower(finalName)]; ok { return nil, iamerr.EntityAlreadyExistsUser(finalName) } } @@ -229,13 +287,15 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t user.Arn = input.NewArn } - if user.UserName != input.UserName { - delete(conf.Users, input.UserName) + if user.UserName != canonical { + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) for _, key := range user.AccessKeys { conf.AccessKeyIndex[key.AccessKeyId] = user.UserName } } conf.Users[user.UserName] = user + conf.UserNameIndex[strings.ToLower(user.UserName)] = user.UserName updated = user return json.Marshal(conf) @@ -257,7 +317,7 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -274,11 +334,11 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey Status: input.Status, CreateDate: input.CreateDate, }) - conf.Users[input.UserName] = user - conf.AccessKeyIndex[input.AccessKeyID] = input.UserName + conf.Users[canonical] = user + conf.AccessKeyIndex[input.AccessKeyID] = canonical created = types.AccessKey{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: input.AccessKeyID, Status: input.Status, SecretAccessKey: input.SecretAccessKey, @@ -303,7 +363,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -320,7 +380,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, iamerr.NoSuchEntityAccessKey(input.AccessKeyID) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -336,7 +396,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -353,7 +413,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID } user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) - conf.Users[username] = user + conf.Users[canonical] = user delete(conf.AccessKeyIndex, accessKeyID) return json.Marshal(conf) @@ -402,7 +462,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -410,7 +470,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) for _, key := range user.AccessKeys { keys = append(keys, types.AccessKeyMetadata{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: key.AccessKeyId, Status: key.Status, CreateDate: key.CreateDate, @@ -459,7 +519,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -490,7 +550,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu }) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -505,7 +565,7 @@ func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName st return nil, err } - user, ok := conf.Users[userName] + _, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -530,7 +590,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName return nil, err } - user, ok := conf.Users[userName] + canonical, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -547,7 +607,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName } user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) - conf.Users[userName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -562,7 +622,7 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return nil, err } - user, ok := conf.Users[input.UserName] + _, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -602,6 +662,160 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return out, nil } +func (s *InternalStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + role.EnsureRoleLastUsed() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + key := strings.ToLower(role.RoleName) + if _, ok := conf.RoleNameIndex[key]; ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + for _, existing := range conf.Roles { + if existing.RoleID == role.RoleID { + return nil, ErrRoleIDAlreadyExists + } + } + + conf.Roles[role.RoleName] = role + conf.RoleNameIndex[key] = role.RoleName + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) ListRoles(_ context.Context, input ListRolesInput) (*ListRolesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + roles := make([]types.Role, 0, len(conf.Roles)) + for _, role := range conf.Roles { + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though it's persisted — + // matches the documented list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, role) + } + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *InternalStore) DeleteRole(_ context.Context, roleName string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + if len(role.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + + delete(conf.Roles, canonical) + delete(conf.RoleNameIndex, strings.ToLower(canonical)) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + var updated types.Role + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + role.AssumeRolePolicyDocument = input.PolicyDocument + conf.Roles[canonical] = role + updated = role + + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(updated), nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) @@ -609,3 +823,10 @@ func cloneUser(user types.User) *types.User { cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } + +func cloneRole(role types.Role) *types.Role { + cloned := role + cloned.Tags = slices.Clone(role.Tags) + cloned.Policies.Inline = slices.Clone(role.Policies.Inline) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index d799d711..f862d00f 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -36,6 +36,7 @@ const MaxInlinePolicyBytesPerUser = 2048 var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") + ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") ) type ListUsersInput struct { @@ -108,6 +109,23 @@ type ListUserPoliciesOutput struct { Marker string } +type ListRolesInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListRolesOutput struct { + Roles []types.Role + IsTruncated bool + Marker string +} + +type UpdateAssumeRolePolicyInput struct { + RoleName string + PolicyDocument string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -126,6 +144,12 @@ type Storer interface { GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) DeleteUserPolicy(ctx context.Context, userName, policyName string) error ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) + + CreateRole(ctx context.Context, role types.Role) (*types.Role, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) + DeleteRole(ctx context.Context, roleName string) error + UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index be0ea36d..e54104a0 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -221,3 +221,166 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDA11111111111111111"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDA22222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { + t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + + got, err := store.GetUser(ctx, "ALICE") + if err != nil { + t.Fatalf("GetUser case-insensitive lookup: %v", err) + } + if got.UserName != "alice" { + t.Fatalf("GetUser case-insensitive lookup = %#v, want canonical casing preserved", got) + } + + if err := store.DeleteUser(ctx, "ALICE"); err != nil { + t.Fatalf("DeleteUser case-insensitive lookup: %v", err) + } + if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) { + t.Fatalf("GetUser after case-insensitive delete err = %v, want NoSuchEntity", err) + } +} + +func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + created := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + roles := []types.Role{ + { + Path: "/engineering/", + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + Arn: "arn:aws:iam::000000000000:role/engineering/alice-role", + CreateDate: created, + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + Tags: []types.Tag{ + {Key: "env", Value: "test"}, + }, + }, + { + Path: "/engineering/platform/", + RoleName: "bob-role", + RoleID: "AROA33333333333333333", + Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role", + CreateDate: created.Add(time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + { + Path: "/ops/", + RoleName: "carol-role", + RoleID: "AROA44444444444444444", + Arn: "arn:aws:iam::000000000000:role/ops/carol-role", + CreateDate: created.Add(2 * time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + } + for _, role := range roles { + created, err := store.CreateRole(ctx, role) + if err != nil { + t.Fatalf("CreateRole(%s): %v", role.RoleName, err) + } + if created.RoleLastUsed == nil { + t.Fatalf("CreateRole(%s) RoleLastUsed = nil, want non-nil empty element", role.RoleName) + } + } + + if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) { + t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err) + } + if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROA55555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { + t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + duplicateID := roles[2] + duplicateID.RoleName = "dave-role" + if _, err := store.CreateRole(ctx, duplicateID); !errors.Is(err, ErrRoleIDAlreadyExists) { + t.Fatalf("CreateRole duplicate id err = %v, want ErrRoleIDAlreadyExists", err) + } + + got, err := store.GetRole(ctx, "ALICE-ROLE") + if err != nil { + t.Fatalf("GetRole: %v", err) + } + if got.RoleName != "alice-role" || got.RoleID != roles[0].RoleID { + t.Fatalf("GetRole = %#v, want alice-role with stable id and preserved casing", got) + } + if !reflect.DeepEqual(got.Tags, roles[0].Tags) { + t.Fatalf("GetRole tags = %#v, want %#v", got.Tags, roles[0].Tags) + } + if got.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + + page1, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRoles page1: %v", err) + } + if len(page1.Roles) != 1 || page1.Roles[0].RoleName != "alice-role" || !page1.IsTruncated || page1.Marker != "alice-role" { + t.Fatalf("page1 = %#v, want truncated alice-role page", page1) + } + if page1.Roles[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", page1.Roles[0].RoleLastUsed) + } + + page2, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRoles page2: %v", err) + } + if len(page2.Roles) != 1 || page2.Roles[0].RoleName != "bob-role" || page2.IsTruncated { + t.Fatalf("page2 = %#v, want final bob-role page", page2) + } + + updatedRole, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{ + RoleName: "alice-role", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, + }) + if err != nil { + t.Fatalf("UpdateAssumeRolePolicy: %v", err) + } + if updatedRole.AssumeRolePolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` { + t.Fatalf("UpdateAssumeRolePolicy result = %#v", updatedRole) + } + if updatedRole.RoleID != roles[0].RoleID { + t.Fatalf("UpdateAssumeRolePolicy identity changed: %#v", updatedRole) + } + if _, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{RoleName: "missing-role", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("UpdateAssumeRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + reopenedRole, err := reopened.GetRole(ctx, "alice-role") + if err != nil { + t.Fatalf("GetRole after reopen: %v", err) + } + if reopenedRole.AssumeRolePolicyDocument != updatedRole.AssumeRolePolicyDocument { + t.Fatalf("reopened AssumeRolePolicyDocument = %q, want %q", reopenedRole.AssumeRolePolicyDocument, updatedRole.AssumeRolePolicyDocument) + } + + if err := reopened.DeleteRole(ctx, "carol-role"); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + if err := reopened.DeleteRole(ctx, "carol-role"); !errors.Is(err, iamerr.NoSuchEntityRole("carol-role")) { + t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 48399724..d54c97a2 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -191,7 +191,45 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return nil } +// findUserKey resolves name to the exact stored KV path segment (the +// original UserName casing used at creation), case-insensitively, by +// listing the users under secretStoragePath and comparing with EqualFold. +// AWS enforces case-insensitive UserName uniqueness but Vault's KV paths +// are plain case-sensitive strings, so a list+compare fallback is needed — +// KV has no native case-insensitive lookup. ok is false both when nothing +// matches and (harmlessly) when the prefix has no children at all. +func (s *VaultStore) findUserKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { + if _, ok, err := s.findUserKey(user.UserName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsUser(user.UserName) + } + userMap, err := userToVaultMap(user) if err != nil { return nil, fmt.Errorf("serialize user: %w", err) @@ -239,11 +277,19 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - return s.deleteByPath(username) + return s.deleteByPath(user.UserName) } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - path := s.secretStoragePath + "/" + username + canonical, ok, err := s.findUserKey(username) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityUser(username) + } + + path := s.secretStoragePath + "/" + canonical resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { @@ -261,7 +307,7 @@ func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, e } } - user, err := parseVaultUser(resp.Data.Data, username) + user, err := parseVaultUser(resp.Data.Data, canonical) if err != nil { return nil, err } @@ -340,13 +386,14 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty if err != nil { return nil, err } + originalName := user.UserName finalName := user.UserName if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { + if !strings.EqualFold(finalName, originalName) { existing, err := s.GetUser(ctx, finalName) if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) { return nil, err @@ -366,12 +413,12 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != input.UserName { + if user.UserName != originalName { // Create at new path first to detect conflicts before deleting the old entry. if _, err := s.CreateUser(ctx, *user); err != nil { return nil, err } - if err := s.deleteByPath(input.UserName); err != nil { + if err := s.deleteByPath(originalName); err != nil { return nil, err } } else if _, err := s.replaceUser(ctx, *user); err != nil { @@ -689,6 +736,273 @@ func (s *VaultStore) deleteByPath(username string) error { return nil } +// rolesPath is the KV prefix under which roles are stored, kept distinct +// from secretStoragePath (which holds users) so listing one entity kind +// never has to filter out the other's keys. +func (s *VaultStore) rolesPath() string { + return s.secretStoragePath + "/roles" +} + +// findRoleKey is findUserKey's counterpart for roles. +func (s *VaultStore) findRoleKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + +func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + if _, ok, err := s.findRoleKey(role.RoleName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + + role.EnsureRoleLastUsed() + + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) + } + + path := s.rolesPath() + "/" + role.RoleName + req := schema.KvV2WriteRequest{ + Data: map[string]any{role.RoleName: roleMap}, + Options: map[string]any{ + "cas": 0, + }, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + // retry once after re-auth + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + if vault.IsErrorStatus(err, http.StatusForbidden) { + return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err) + } + return nil, err + } + } + return cloneRole(role), nil +} + +func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { + canonical, ok, err := s.findRoleKey(roleName) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + path := s.rolesPath() + "/" + canonical + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityRole(roleName) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityRole(roleName) + } + return nil, err + } + } + + role, err := parseVaultRole(resp.Data.Data, canonical) + if err != nil { + return nil, err + } + return cloneRole(role), nil +} + +func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + reauthErr := s.reAuthIfNeeded(err) + if reauthErr != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, err + } + } + + roles := make([]types.Role, 0, len(resp.Data.Keys)) + for _, key := range resp.Data.Keys { + role, err := s.GetRole(ctx, key) + if err != nil { + return nil, err + } + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though GetRole (reused + // above to fetch each entry) attaches it — matches the documented + // list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, *role) + } + + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + if len(role.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + return s.deleteRoleByPath(role.RoleName) +} + +func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + role.AssumeRolePolicyDocument = input.PolicyDocument + + return s.replaceRole(ctx, *role) +} + +// replaceRole overwrites the stored document for role.RoleName by deleting +// all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { + if err := s.deleteRoleByPath(role.RoleName); err != nil { + return nil, err + } + return s.CreateRole(ctx, role) +} + +// deleteRoleByPath permanently removes a role secret and all its versions +// without checking for existence first. +func (s *VaultStore) deleteRoleByPath(roleName string) error { + path := s.rolesPath() + "/" + roleName + _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + return err + } + } + return nil +} + +var errInvalidVaultRole = errors.New("invalid role entry in vault secrets engine") + +// roleToVaultMap is userToVaultMap's counterpart for roles. +func roleToVaultMap(role types.Role) (map[string]any, error) { + b, err := json.Marshal(role) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// parseVaultRole reconstructs a Role from the raw map[string]any that vault +// returns. The outer key is the role name. +func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { + raw, ok := data[roleName] + if !ok { + return types.Role{}, errInvalidVaultRole + } + roleMap, ok := raw.(map[string]any) + if !ok { + return types.Role{}, errInvalidVaultRole + } + b, err := json.Marshal(roleMap) + if err != nil { + return types.Role{}, fmt.Errorf("re-marshal vault role: %w", err) + } + var role types.Role + if err := json.Unmarshal(b, &role); err != nil { + return types.Role{}, fmt.Errorf("unmarshal vault role: %w", err) + } + return role, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/role.go b/iamapi/types/role.go new file mode 100644 index 00000000..fada0857 --- /dev/null +++ b/iamapi/types/role.go @@ -0,0 +1,113 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +type Role struct { + Path string `xml:",omitempty"` + RoleName string `xml:",omitempty"` + RoleID string `xml:"RoleId"` + Arn string `xml:"Arn"` + CreateDate time.Time `xml:"CreateDate"` + AssumeRolePolicyDocument string `xml:",omitempty"` + Description string `xml:",omitempty"` + MaxSessionDuration int32 `xml:"MaxSessionDuration,omitempty"` + RoleLastUsed *RoleLastUsed + Tags []Tag `xml:"Tags>member,omitempty"` + Policies Policies `xml:"-"` // unused until role inline-policy CRUD exists; see DeleteRole conflict check +} + +type RoleLastUsed struct { + LastUsedDate time.Time `xml:",omitempty"` + Region string `xml:",omitempty"` +} + +// EnsureRoleLastUsed defaults RoleLastUsed to a zero value if unset, +// without clobbering an already-set value. +func (r *Role) EnsureRoleLastUsed() { + if r.RoleLastUsed == nil { + r.RoleLastUsed = &RoleLastUsed{} + } +} + +type CreateRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateRoleResponse"` + Result CreateRoleResult `xml:"CreateRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateRoleResult struct { + Role *Role +} + +type GetRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRoleResponse"` + Result GetRoleResult `xml:"GetRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRoleResult struct { + Role *Role +} + +type ListRolesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolesResponse"` + Result ListRolesResult `xml:"ListRolesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolesResult struct { + Roles Roles + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type Roles struct { + Members []Role `xml:"member"` +} + +type DeleteRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRoleResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateAssumeRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAssumeRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateAssumeRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/runiamtests.sh b/runiamtests.sh index 73dacc5a..e12a6cce 100755 --- a/runiamtests.sh +++ b/runiamtests.sh @@ -152,7 +152,7 @@ fi vault_policy=$(printf '%s\n' \ "path \"$VAULT_MOUNT_PATH/data/$VAULT_SECRET_PATH/*\" { capabilities = [\"create\", \"update\", \"read\"] }" \ "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/\" { capabilities = [\"list\"] }" \ - "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\"] }") + "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\", \"list\"] }") vault_policy_payload=$(jq -nc --arg policy "$vault_policy" '{policy: $policy}') vault_request PUT "sys/policies/acl/$VAULT_POLICY_NAME" "$vault_policy_payload" >/dev/null diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 8186572e..77a58301 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1121,6 +1121,7 @@ func TestIAMQueryAuth(ts *TestState) { func TestIAMCreateUser(ts *TestState) { ts.Run(IAMCreateUser_user_already_exists) + ts.Run(IAMCreateUser_already_exists_case_insensitive) ts.Run(IAMCreateUser_invalid_user_name) ts.Run(IAMCreateUser_long_user_name) ts.Run(IAMCreateUser_missing_user_name) @@ -1274,6 +1275,68 @@ func TestIAMListUserPolicies(ts *TestState) { ts.Run(IAMListUserPolicies_pagination) } +func TestIAMCreateRole(ts *TestState) { + ts.Run(IAMCreateRole_missing_role_name) + ts.Run(IAMCreateRole_invalid_role_name) + ts.Run(IAMCreateRole_long_role_name) + ts.Run(IAMCreateRole_already_exists) + ts.Run(IAMCreateRole_already_exists_case_insensitive) + ts.Run(IAMCreateRole_invalid_path) + ts.Run(IAMCreateRole_long_path) + ts.Run(IAMCreateRole_missing_assume_role_policy_document) + ts.Run(IAMCreateRole_non_ascii_assume_role_policy_document) + ts.Run(IAMCreateRole_trust_policy_size_limit_exceeded) + ts.Run(IAMCreateRole_description_invalid_charset) + ts.Run(IAMCreateRole_description_too_long) + ts.Run(IAMCreateRole_max_session_duration_invalid_format) + ts.Run(IAMCreateRole_max_session_duration_too_low) + ts.Run(IAMCreateRole_max_session_duration_too_high) + ts.Run(IAMCreateRole_duplicate_tag_keys) + ts.Run(IAMCreateRole_success) + ts.Run(IAMCreateRole_defaults) + ts.Run(IAMCreateRole_trust_policy_document_grammar) +} + +func TestIAMGetRole(ts *TestState) { + ts.Run(IAMGetRole_missing_role_name) + ts.Run(IAMGetRole_invalid_role_name) + ts.Run(IAMGetRole_long_role_name) + ts.Run(IAMGetRole_non_existing_role) + ts.Run(IAMGetRole_success) +} + +func TestIAMListRoles(ts *TestState) { + ts.Run(IAMListRoles_invalid_path_prefix) + ts.Run(IAMListRoles_long_path_prefix) + ts.Run(IAMListRoles_invalid_max_items) + ts.Run(IAMListRoles_invalid_max_items_format) + ts.Run(IAMListRoles_empty_result) + ts.Run(IAMListRoles_success) + ts.Run(IAMListRoles_path_prefix) + ts.Run(IAMListRoles_pagination) + ts.Run(IAMListRoles_path_prefix_pagination) +} + +func TestIAMDeleteRole(ts *TestState) { + ts.Run(IAMDeleteRole_missing_role_name) + ts.Run(IAMDeleteRole_invalid_role_name) + ts.Run(IAMDeleteRole_long_role_name) + ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_success) +} + +func TestIAMUpdateAssumeRolePolicy(ts *TestState) { + ts.Run(IAMUpdateAssumeRolePolicy_missing_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_missing_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_invalid_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_long_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_non_existing_role) + ts.Run(IAMUpdateAssumeRolePolicy_non_ascii_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded) + ts.Run(IAMUpdateAssumeRolePolicy_success) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1291,6 +1354,11 @@ func TestIAM(ts *TestState) { TestIAMGetUserPolicy(ts) TestIAMDeleteUserPolicy(ts) TestIAMListUserPolicies(ts) + TestIAMCreateRole(ts) + TestIAMGetRole(ts) + TestIAMListRoles(ts) + TestIAMDeleteRole(ts) + TestIAMUpdateAssumeRolePolicy(ts) } func TestAccessControl(ts *TestState) { @@ -1636,6 +1704,7 @@ func GetIntTests() IntTests { "IAMQueryAuth_invalid_sha256_payload_hash_ignored": IAMQueryAuth_invalid_sha256_payload_hash_ignored, "IAMQueryAuth_with_expect_header": IAMQueryAuth_with_expect_header, "IAMCreateUser_user_already_exists": IAMCreateUser_user_already_exists, + "IAMCreateUser_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, "IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name, "IAMCreateUser_long_user_name": IAMCreateUser_long_user_name, "IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name, @@ -1748,6 +1817,53 @@ func GetIntTests() IntTests { "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, "IAMListUserPolicies_success": IAMListUserPolicies_success, "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, + "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, + "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, + "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, + "IAMCreateRole_already_exists": IAMCreateRole_already_exists, + "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, + "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, + "IAMCreateRole_long_path": IAMCreateRole_long_path, + "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, + "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, + "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, + "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, + "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, + "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, + "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, + "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, + "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, + "IAMCreateRole_success": IAMCreateRole_success, + "IAMCreateRole_defaults": IAMCreateRole_defaults, + "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, + "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, + "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, + "IAMGetRole_long_role_name": IAMGetRole_long_role_name, + "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, + "IAMGetRole_success": IAMGetRole_success, + "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, + "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, + "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, + "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, + "IAMListRoles_empty_result": IAMListRoles_empty_result, + "IAMListRoles_success": IAMListRoles_success, + "IAMListRoles_path_prefix": IAMListRoles_path_prefix, + "IAMListRoles_pagination": IAMListRoles_pagination, + "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, + "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, + "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, + "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, + "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_success": IAMDeleteRole_success, + "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, + "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, + "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, + "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, + "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, + "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, + "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, + "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, + "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_create_role.go b/tests/integration/iam_create_role.go new file mode 100644 index 00000000..6bddf576 --- /dev/null +++ b/tests/integration/iam_create_role.go @@ -0,0 +1,433 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" +) + +// validTrustPolicyDocument is a minimal role trust policy accepted by +// ParseTrust: any principal may assume the role via sts:AssumeRole. +const validTrustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +var integrationIAMRoleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) + +func IAMCreateRole_missing_role_name(s *S3Conf) error { + testName := "IAMCreateRole_missing_role_name" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMCreateRole_invalid_role_name(s *S3Conf) error { + testName := "IAMCreateRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String("invalid/role"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMCreateRole_long_role_name(s *S3Conf) error { + testName := "IAMCreateRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMCreateRole_already_exists(s *S3Conf) error { + testName := "IAMCreateRole_already_exists" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(roleName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateRole_already_exists_case_insensitive" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + upperName := strings.ToUpper(roleName) + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &upperName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(upperName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_invalid_path(s *S3Conf) error { + testName := "IAMCreateRole_invalid_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("invalid"), + }) + return checkIAMApiErr(err, iamerr.InvalidPath("path")) + }) +} + +func IAMCreateRole_long_path(s *S3Conf) error { + testName := "IAMCreateRole_long_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("/" + strings.Repeat("a", 511) + "/"), + }) + return checkIAMApiErr(err, iamerr.PathTooLong("path", 512)) + }) +} + +func IAMCreateRole_missing_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_missing_assume_role_policy_document" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_non_ascii_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_non_ascii_assume_role_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_size_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }) +} + +func IAMCreateRole_description_invalid_charset(s *S3Conf) error { + testName := "IAMCreateRole_description_invalid_charset" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidDescriptionCharset("description")) + }) +} + +func IAMCreateRole_description_too_long(s *S3Conf) error { + testName := "IAMCreateRole_description_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String(strings.Repeat("a", 1001)), + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("description", 1000)) + }) +} + +func IAMCreateRole_max_session_duration_invalid_format(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_invalid_format" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + "MaxSessionDuration": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMCreateRole_max_session_duration_too_low(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_low" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(3599), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooLow()) + }) +} + +func IAMCreateRole_max_session_duration_too_high(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_high" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(43201), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooHigh()) + }) +} + +func IAMCreateRole_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateRole_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Tags: []iamtypes.Tag{ + {Key: aws.String("key"), Value: aws.String("one")}, + {Key: aws.String("KEY"), Value: aws.String("two")}, + }, + }) + return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive.")) + }) +} + +func IAMCreateRole_success(s *S3Conf) error { + testName := "IAMCreateRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_defaults(s *S3Conf) error { + testName := "IAMCreateRole_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/", "", 3600, validTrustPolicyDocument, false) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkCreateRoleTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkCreateRoleTrustPolicyCase verifies doc is accepted/rejected as +// expected when used as a fresh role's AssumeRolePolicyDocument. +func checkCreateRoleTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) error { + roleName := newIAMRoleName() + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if err != nil { + return fmt.Errorf("CreateRole: %w", err) + } + return deleteIAMRole(client, roleName) + } + return checkIAMApiErr(err, wantErr) +} + +func createIAMRole(client *iam.Client, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateRole(ctx, input) +} + +func newIAMRoleName() string { + return "create-role-" + genRandString(16) +} + +// checkCreateRoleOutput verifies the fields of a CreateRoleOutput-shaped role. +func checkCreateRoleOutput(out *iam.CreateRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected CreateRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("CreateRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} + +func checkRoleFields(operation string, role *iamtypes.Role, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool, requestID string, hasRequestID bool) error { + if role == nil { + return fmt.Errorf("expected %s output role", operation) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role path to be %q, instead got %q", path, aws.ToString(role.Path)) + } + if aws.ToString(role.RoleName) != roleName { + return fmt.Errorf("expected role name to be %q, instead got %q", roleName, aws.ToString(role.RoleName)) + } + expectedARN := "arn:aws:iam::000000000000:role" + path + roleName + if aws.ToString(role.Arn) != expectedARN { + return fmt.Errorf("expected role ARN to be %q, instead got %q", expectedARN, aws.ToString(role.Arn)) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected role create date") + } + if aws.ToString(role.Description) != description { + return fmt.Errorf("expected role description to be %q, instead got %q", description, aws.ToString(role.Description)) + } + if aws.ToInt32(role.MaxSessionDuration) != maxSessionDuration { + return fmt.Errorf("expected role max session duration to be %d, instead got %d", maxSessionDuration, aws.ToInt32(role.MaxSessionDuration)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(role.AssumeRolePolicyDocument), err) + } + if gotDocument != wantDocument { + return fmt.Errorf("expected assume role policy document %q, instead got %q", wantDocument, gotDocument) + } + if role.RoleLastUsed == nil { + return fmt.Errorf("expected role RoleLastUsed to be non-nil (empty element)") + } + if expectTags { + if len(role.Tags) != 1 || aws.ToString(role.Tags[0].Key) != "env" || aws.ToString(role.Tags[0].Value) != "test" { + return fmt.Errorf("expected role tag env=test, instead got %#v", role.Tags) + } + } else if len(role.Tags) != 0 { + return fmt.Errorf("expected no role tags, instead got %#v", role.Tags) + } + if !hasRequestID || requestID == "" { + return fmt.Errorf("expected %s response request id", operation) + } + + return nil +} diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go index 9f64cae1..ca1f435e 100644 --- a/tests/integration/iam_create_user.go +++ b/tests/integration/iam_create_user.go @@ -47,6 +47,27 @@ func IAMCreateUser_user_already_exists(s *S3Conf) error { }) } +func IAMCreateUser_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateUser_already_exists_case_insensitive" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{ + UserName: &userName, + }); err != nil { + return err + } + + upperName := strings.ToUpper(userName) + _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &upperName}) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsUser(upperName)) + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + func IAMCreateUser_invalid_user_name(s *S3Conf) error { testName := "IAMCreateUser_invalid_user_name" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role.go b/tests/integration/iam_delete_role.go new file mode 100644 index 00000000..ecb30031 --- /dev/null +++ b/tests/integration/iam_delete_role.go @@ -0,0 +1,95 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteRole_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_missing_role_name" + body := []byte("Action=DeleteRole&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("RoleName")) + }) +} + +func IAMDeleteRole_invalid_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMDeleteRole_long_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMDeleteRole_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + err := deleteIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRole_success(s *S3Conf) error { + testName := "IAMDeleteRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + if err := deleteIAMRole(client, roleName); err != nil { + return err + } + + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func deleteIAMRole(client *iam.Client, roleName string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: &roleName}) + return err +} diff --git a/tests/integration/iam_get_role.go b/tests/integration/iam_get_role.go new file mode 100644 index 00000000..4e20525f --- /dev/null +++ b/tests/integration/iam_get_role.go @@ -0,0 +1,122 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetRole_missing_role_name(s *S3Conf) error { + testName := "IAMGetRole_missing_role_name" + body := []byte("Action=GetRole&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingParameter("RoleName")) + }) +} + +func IAMGetRole_invalid_role_name(s *S3Conf) error { + testName := "IAMGetRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMGetRole_long_role_name(s *S3Conf) error { + testName := "IAMGetRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMGetRole_non_existing_role(s *S3Conf) error { + testName := "IAMGetRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRole_success(s *S3Conf) error { + testName := "IAMGetRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }); err != nil { + return err + } + + out, err := getIAMRole(client, roleName) + if err != nil { + deleteErr := deleteIAMRole(client, roleName) + if deleteErr != nil { + return fmt.Errorf("get role: %v; delete role: %w", err, deleteErr) + } + return err + } + + checkErr := checkGetRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRole(client *iam.Client, roleName string) (*iam.GetRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRole(ctx, &iam.GetRoleInput{RoleName: &roleName}) +} + +// checkGetRoleOutput verifies the fields of a GetRoleOutput-shaped role. +func checkGetRoleOutput(out *iam.GetRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected GetRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("GetRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} diff --git a/tests/integration/iam_list_roles.go b/tests/integration/iam_list_roles.go new file mode 100644 index 00000000..0849a288 --- /dev/null +++ b/tests/integration/iam_list_roles.go @@ -0,0 +1,375 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "reflect" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListRoles_invalid_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_invalid_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + expected := iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.") + for _, pathPrefix := range []string{"invalid", "/invalid\n"} { + _, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: aws.String(pathPrefix)}) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("PathPrefix %q: %w", pathPrefix, checkErr) + } + } + return nil + }) +} + +func IAMListRoles_long_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_long_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/" + strings.Repeat("a", 512) + _, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: &pathPrefix}) + return checkIAMApiErr(err, iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.")) + }) +} + +func IAMListRoles_invalid_max_items(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, maxItems := range []int32{-1, 0, 1001} { + _, err := listIAMRoles(client, &iam.ListRolesInput{MaxItems: aws.Int32(maxItems)}) + expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems)) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListRoles_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListRoles"}, + "Version": {"2010-05-08"}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") + return checkIAMAuthRequest(s, req, expected) + }) +} + +func IAMListRoles_empty_result(s *S3Conf) error { + testName := "IAMListRoles_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/list-roles-" + genRandString(16) + "/" + input := &iam.ListRolesInput{PathPrefix: &pathPrefix} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRolesOutput(second); err != nil { + return err + } + if len(first.Roles) != 0 || len(second.Roles) != 0 { + return fmt.Errorf("expected consistent empty results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) +} + +func IAMListRoles_success(s *S3Conf) error { + testName := "IAMListRoles_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := map[string]string{"list-roles-" + genRandString(16): path} + return withIAMListRoles(client, roles, func() error { + out, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: &path}) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(out); err != nil { + return err + } + return checkIAMListRoles(out.Roles, roles) + }) + }) +} + +func IAMListRoles_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + engineeringPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := map[string]string{ + namePrefix + "-root": basePath, + namePrefix + "-z": engineeringPath, + namePrefix + "-a": engineeringPath + "platform/", + namePrefix + "-ops": basePath + "operations/", + } + expected := map[string]string{ + namePrefix + "-a": engineeringPath + "platform/", + namePrefix + "-z": engineeringPath, + } + return withIAMListRoles(client, roles, func() error { + input := &iam.ListRolesInput{PathPrefix: &engineeringPath} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRoles(first.Roles, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) { + return fmt.Errorf("expected consistent results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) + }) +} + +func IAMListRoles_pagination(s *S3Conf) error { + testName := "IAMListRoles_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := make(map[string]string, 5) + for range 5 { + roles["list-roles-"+genRandString(16)] = path + } + return withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &path, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, roles); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent pagination results") + } + return nil + }) + }) +} + +func IAMListRoles_path_prefix_pagination(s *S3Conf) error { + testName := "IAMListRoles_path_prefix_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + matchingPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := map[string]string{ + namePrefix + "-outside": basePath, + namePrefix + "-e": matchingPath, + namePrefix + "-d": matchingPath, + namePrefix + "-c": matchingPath + "platform/", + namePrefix + "-b": matchingPath + "storage/", + namePrefix + "-a": matchingPath + "storage/archive/", + namePrefix + "-ops": basePath + "operations/", + } + expected := map[string]string{ + namePrefix + "-a": matchingPath + "storage/archive/", + namePrefix + "-b": matchingPath + "storage/", + namePrefix + "-c": matchingPath + "platform/", + namePrefix + "-d": matchingPath, + namePrefix + "-e": matchingPath, + } + return withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &matchingPath, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent filtered pagination results") + } + return nil + }) + }) +} + +func listIAMRoles(client *iam.Client, input *iam.ListRolesInput) (*iam.ListRolesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRoles(ctx, input) +} + +func withIAMListRoles(client *iam.Client, roles map[string]string, test func() error) (err error) { + created := make([]string, 0, len(roles)) + defer func() { + for _, name := range created { + if deleteErr := deleteIAMRole(client, name); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM role %q: %w", name, deleteErr)) + } + } + }() + + for name, path := range roles { + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &name, + Path: &path, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + created = append(created, name) + } + return test() +} + +func collectIAMListRolePages(client *iam.Client, input iam.ListRolesInput) ([]*iam.ListRolesOutput, error) { + var pages []*iam.ListRolesOutput + for { + out, err := listIAMRoles(client, &input) + if err != nil { + return nil, err + } + if err := checkIAMListRolesOutput(out); err != nil { + return nil, err + } + pages = append(pages, out) + if !out.IsTruncated { + return pages, nil + } + input.Marker = out.Marker + } +} + +func checkIAMListRolesOutput(out *iam.ListRolesOutput) error { + if out == nil { + return fmt.Errorf("expected ListRoles output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRoles response request id") + } + if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") { + return fmt.Errorf("expected marker only when ListRoles output is truncated") + } + for _, role := range out.Roles { + if aws.ToString(role.Path) == "" || aws.ToString(role.RoleName) == "" || aws.ToString(role.RoleId) == "" || aws.ToString(role.Arn) == "" || role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected all required fields for listed role, instead got %#v", role) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.RoleLastUsed != nil { + return fmt.Errorf("expected ListRoles RoleLastUsed to be nil (list/get asymmetry), instead got %#v", role.RoleLastUsed) + } + } + return nil +} + +func checkIAMListRoles(roles []iamtypes.Role, expected map[string]string) error { + if len(roles) != len(expected) { + return fmt.Errorf("expected %d roles, instead got %d: %v", len(expected), len(roles), iamListRoleNames(roles)) + } + names := iamListRoleNames(roles) + if !sort.StringsAreSorted(names) { + return fmt.Errorf("expected roles sorted by role name, instead got %v", names) + } + for _, role := range roles { + name := aws.ToString(role.RoleName) + path, ok := expected[name] + if !ok { + return fmt.Errorf("unexpected listed role %q", name) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role %q path %q, instead got %q", name, path, aws.ToString(role.Path)) + } + if want := "arn:aws:iam::000000000000:role" + path + name; aws.ToString(role.Arn) != want { + return fmt.Errorf("expected role %q ARN %q, instead got %q", name, want, aws.ToString(role.Arn)) + } + } + return nil +} + +func checkIAMListRolePages(pages []*iam.ListRolesOutput, sizes []int, expected map[string]string) error { + if len(pages) != len(sizes) { + return fmt.Errorf("expected %d pages, instead got %d", len(sizes), len(pages)) + } + var roles []iamtypes.Role + for i, page := range pages { + if len(page.Roles) != sizes[i] { + return fmt.Errorf("expected page %d to contain %d roles, instead got %d", i+1, sizes[i], len(page.Roles)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + roles = append(roles, page.Roles...) + } + return checkIAMListRoles(roles, expected) +} + +func iamListRolePageValues(pages []*iam.ListRolesOutput) [][]string { + values := make([][]string, len(pages)) + for i, page := range pages { + values[i] = append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListRoleNames(page.Roles)...) + } + return values +} + +func iamListRoleNames(roles []iamtypes.Role) []string { + names := make([]string, len(roles)) + for i, role := range roles { + names[i] = aws.ToString(role.RoleName) + } + return names +} diff --git a/tests/integration/iam_update_assume_role_policy.go b/tests/integration/iam_update_assume_role_policy.go new file mode 100644 index 00000000..2935e0f5 --- /dev/null +++ b/tests/integration/iam_update_assume_role_policy.go @@ -0,0 +1,253 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" +) + +func IAMUpdateAssumeRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyDocument": {validTrustPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMUpdateAssumeRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_invalid_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("invalid/role"), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMUpdateAssumeRolePolicy_long_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(strings.Repeat("a", 129)), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("asdfadsf"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateAssumeRolePolicy_success(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + created, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := func() error { + const updatedDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + out, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(updatedDocument), + }) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected UpdateAssumeRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UpdateAssumeRolePolicy response request id") + } + + got, err := getIAMRole(client, roleName) + if err != nil { + return err + } + if got == nil || got.Role == nil || created == nil || created.Role == nil { + return fmt.Errorf("expected created and updated roles") + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.Role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(got.Role.AssumeRolePolicyDocument), err) + } + if gotDocument != updatedDocument { + return fmt.Errorf("expected updated assume role policy document %q, instead got %q", updatedDocument, gotDocument) + } + if aws.ToString(got.Role.RoleId) != aws.ToString(created.Role.RoleId) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role id, want %q, instead got %q", aws.ToString(created.Role.RoleId), aws.ToString(got.Role.RoleId)) + } + if got.Role.CreateDate == nil || created.Role.CreateDate == nil || !got.Role.CreateDate.Equal(*created.Role.CreateDate) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role create date") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateIAMAssumeRolePolicy(client *iam.Client, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UpdateAssumeRolePolicy(ctx, input) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkUpdateAssumeRolePolicyTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkUpdateAssumeRolePolicyTrustPolicyCase verifies doc is accepted/rejected +// as expected when used to update an existing role's trust policy. +func checkUpdateAssumeRolePolicyTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) (err error) { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("create base role: %w", err) + } + defer func() { + if deleteErr := deleteIAMRole(client, roleName); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("cleanup: %w", deleteErr)) + } + }() + + _, updateErr := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if updateErr != nil { + return fmt.Errorf("UpdateAssumeRolePolicy: %w", updateErr) + } + return nil + } + return checkIAMApiErr(updateErr, wantErr) +} diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 2a2e4856..48e5f63d 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -934,6 +934,60 @@ func checkIAMApiErr(err error, expected iamerr.APIError) error { return nil } +type trustPolicyGrammarCase struct { + name string + doc string + wantErr iamerr.APIError // nil means the document must be accepted +} + +// trustPolicyGrammarCases covers the role trust-policy grammar +var trustPolicyGrammarCases = []trustPolicyGrammarCase{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + {"unrelated condition block ignored (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"aws:SourceAccount":"123456789012"}}}]}`, nil}, + + {"invalid json syntax", `{invalid json`, iamerr.MalformedPolicyDocument("This policy contains invalid Json")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The policy must contain a valid version string")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Effect")}, + + {"missing principal (opposite of an identity policy, which forbids it)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal")}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!")}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Principal must be a JSON object.")}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Syntax error in policy.")}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal key wrong case (looks like it should work, key match is case-sensitive)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "service"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (valid on real AWS, unsupported by this gateway)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.")}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, iamerr.MalformedPolicyDocument("Missing required field Action")}, + {"bare wildcard action rejected (legal in an identity policy, not here)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"one non-sts action in an otherwise-valid array rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","s3:GetObject"]}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Has prohibited field Resource")}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.")}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique")}, + + {"cognito federated without condition (looks valid, Cognito needs a Condition)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider")}, +} + func putObjects(client *s3.Client, objs []string, bucket string) ([]types.Object, error) { var contents []types.Object var size int64 From 3b8e6295e8080c7060ed6dc6df41f8cd7845f854 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Thu, 16 Jul 2026 22:33:50 +0400 Subject: [PATCH 4/7] feat: add IAM role inline policy CRUD Add support for the `PutRolePolicy`, `GetRolePolicy`, `DeleteRolePolicy`, and `ListRolePolicies` actions in the IAM-compatible gateway service, extending role management with the same inline-policy lifecycle already available for IAM users. `PutRolePolicy` validates the policy name and document, parses the document for AWS-compatible syntax and semantic errors (missing actions/resources, malformed ARNs, disallowed principals, duplicate statement IDs, and so on), and rejects documents once the role's aggregate inline-policy size would exceed `MaxInlinePolicyBytesPerRole` (10240 bytes, distinct from the 2048-byte quota enforced for users). Putting a policy under an existing name overwrites its document in place. `GetRolePolicy` and `DeleteRolePolicy` look up or remove a named inline policy from a role, returning a `NoSuchEntity` error when the role or the policy is not found. `ListRolePolicies` returns a role's inline policy names in sorted order with marker-based pagination. These actions are implemented for both the internal file-backed store and the Vault-backed store, wired into the IAM API router, and given their own XML response types under `iamapi/types`. A new `NoSuchEntityRolePolicy` error was added to `iamapi/iamerr` to mirror the existing user-policy error. --- iamapi/controller.go | 130 +++++++ iamapi/controller_test.go | 370 +++++++++++++++++++ iamapi/iamerr/errors.go | 4 + iamapi/router.go | 5 + iamapi/storage/internal.go | 153 ++++++++ iamapi/storage/storer.go | 27 ++ iamapi/storage/storer_test.go | 127 +++++++ iamapi/storage/vault.go | 116 ++++++ iamapi/types/policy.go | 50 +++ tests/integration/group-tests.go | 76 ++++ tests/integration/iam_delete_role.go | 33 ++ tests/integration/iam_delete_role_policy.go | 212 +++++++++++ tests/integration/iam_get_role_policy.go | 171 +++++++++ tests/integration/iam_list_role_policies.go | 235 ++++++++++++ tests/integration/iam_put_role_policy.go | 389 ++++++++++++++++++++ 15 files changed, 2098 insertions(+) create mode 100644 tests/integration/iam_delete_role_policy.go create mode 100644 tests/integration/iam_get_role_policy.go create mode 100644 tests/integration/iam_list_role_policies.go create mode 100644 tests/integration/iam_put_role_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index f224d24b..0cd44868 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -718,3 +718,133 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil } + +func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutRolePolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required PutRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + // Confirm the role exists before inspecting policy document content + if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to get IAM role %q for PutRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutRolePolicy(ctx.Context(), storage.PutRolePolicyInput{ + RoleName: roleName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.PutRolePolicyResponse{}}, nil +} + +func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetRolePolicy(ctx.Context(), roleName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.GetRolePolicyResponse{ + Result: types.GetRolePolicyResult{ + RoleName: roleName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRolePolicy(ctx.Context(), roleName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRolePolicyResponse{}}, nil +} + +func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRolePolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRolePolicies(ctx.Context(), storage.ListRolePoliciesInput{ + RoleName: roleName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM role policies for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.ListRolePoliciesResponse{ + Result: types.ListRolePoliciesResult{ + PolicyNames: types.PolicyNameList{Members: out.PolicyNames}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index dd975302..3c0cefa8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -1301,6 +1301,376 @@ func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { } } +func TestIAMApiControllerRolePolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createRole := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if createRole.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createRole.StatusCode, readBody(t, createRole)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutRolePolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutRolePolicyResponse" { + t.Fatalf("PutRolePolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutRolePolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRolePolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRolePolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.RoleName != "my-role" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetRolePolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetRolePolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument) + } + decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if decoded != policyDoc { + t.Fatalf("GetRolePolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRolePolicies"}, + "RoleName": {"my-role"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRolePolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolePoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListRolePolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListRolePolicies IsTruncated = true, want false") + } + + // Re-Put-ing the same PolicyName replaces it rather than erroring or + // stacking toward the aggregate size quota. + overwritePut := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutRolePolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetRolePolicyResponse + unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut) + overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if !strings.Contains(overwriteDecoded, "Deny") { + t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteRolePolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteRolePolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRolePolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.") + + // A second delete of the same (now-gone) policy is a hard error, not an + // idempotent success. + doubleDelete := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupRole bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing policy name", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing role name", + params: url.Values{"Action": {"PutRolePolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "put long policy name", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "put non-ascii policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.", + }, + { + name: "put role does not exist", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put nonexistent role wins over malformed document", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": { + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, + }}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Policy document should not specify a principal.", + }, + { + name: "get role does not exist", + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupRole: true, + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete role does not exist", + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupRole: true, + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list role does not exist", + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupRole: true, + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupRole { + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerDeleteRolePolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deleteRole := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + requireIAMError(t, deleteRole, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteRoleAfter := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + if deleteRoleAfter.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteRoleAfter.StatusCode, readBody(t, deleteRoleAfter)) + } +} + +func TestIAMApiControllerPutRolePolicyOversizedDocument(t *testing.T) { + // A >131072 byte PolicyDocument does not fit in a GET query string + // against this test server's header/URL read-buffer limit, matching + // real IAM's own guidance to use POST rather than GET for large + // policy documents - so this one case is exercised over POST directly + // rather than through the doIAMAction GET helper used elsewhere. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {strings.Repeat("x", 131073)}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072") +} + +func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { + // The role's aggregate inline-policy quota (10240 bytes) is well over + // this test server's GET header/URL read-buffer limit, so this case + // is exercised over POST, same as TestIAMApiControllerPutRolePolicyOversizedDocument. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {oversizedDoc}, + }) + requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 4c919a0e..1df3877e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -453,6 +453,10 @@ func NoSuchEntityUserPolicy(userName, policyName string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) } +func NoSuchEntityRolePolicy(roleName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role policy with name %s cannot be found.", policyName), http.StatusNotFound) +} + func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error { return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) } diff --git a/iamapi/router.go b/iamapi/router.go index 4e6444e9..ddb39bd2 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -68,6 +68,11 @@ func (r *IAMApiRouter) Init() { "ListRoles": ctrl.ListRoles, "DeleteRole": ctrl.DeleteRole, "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + // Role Inline Policy CRUD + "PutRolePolicy": ctrl.PutRolePolicy, + "GetRolePolicy": ctrl.GetRolePolicy, + "DeleteRolePolicy": ctrl.DeleteRolePolicy, + "ListRolePolicies": ctrl.ListRolePolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 81a76a61..536418d8 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -816,6 +816,159 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs return cloneRole(updated), nil } +func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return nil, iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetRolePolicy(_ context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *InternalStore) DeleteRolePolicy(_ context.Context, roleName, policyName string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListRolePolicies(_ context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolePoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index f862d00f..aa915c19 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -33,6 +33,10 @@ const MaxAccessKeysPerUser = 2 // all of a single IAM user's inline policy documents combined const MaxInlinePolicyBytesPerUser = 2048 +// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of +// all of a single IAM role's inline policy documents combined +const MaxInlinePolicyBytesPerRole = 10240 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -126,6 +130,24 @@ type UpdateAssumeRolePolicyInput struct { PolicyDocument string } +type PutRolePolicyInput struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListRolePoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -150,6 +172,11 @@ type Storer interface { ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) DeleteRole(ctx context.Context, roleName string) error UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) + + PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error + GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) + DeleteRolePolicy(ctx context.Context, roleName, policyName string) error + ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index e54104a0..59c95a56 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -384,3 +384,130 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreRolePolicyCRUD(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateRole(ctx, types.Role{ + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "ALICE-ROLE", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "missing-role", PolicyName: "P", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("PutRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + entry, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy: %v", err) + } + if entry.PolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` { + t.Fatalf("GetRolePolicy document = %q", entry.PolicyDocument) + } + if entry.CreateDate.IsZero() || entry.UpdateDate.IsZero() { + t.Fatalf("GetRolePolicy CreateDate/UpdateDate zero: %#v", entry) + } + if _, err := store.GetRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("GetRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if _, err := store.GetRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("GetRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + // Overwriting an existing PolicyName replaces its document rather than + // stacking toward the aggregate size quota. + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("overwrite PutRolePolicy: %v", err) + } + overwritten, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy after overwrite: %v", err) + } + if !strings.Contains(overwritten.PolicyDocument, "Deny") { + t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwritten.PolicyDocument) + } + + // Aggregate inline policy size for a role is capped at + // MaxInlinePolicyBytesPerRole (10240), distinct from and larger than + // the 2048 byte cap for users. + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "alice-role", PolicyName: "TooBig", PolicyDocument: oversized}); !errors.Is(err, iamerr.InlinePolicyQuotaExceeded("role", "alice-role", MaxInlinePolicyBytesPerRole)) { + t.Fatalf("PutRolePolicy oversized err = %v, want LimitExceeded", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "SecondPolicy", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy second policy: %v", err) + } + + list, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "ALICE-ROLE", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRolePolicies page1: %v", err) + } + if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "ReadOnly" || !list.IsTruncated || list.Marker != "ReadOnly" { + t.Fatalf("ListRolePolicies page1 = %#v, want truncated ReadOnly page", list) + } + page2, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "alice-role", Marker: list.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRolePolicies page2: %v", err) + } + if len(page2.PolicyNames) != 1 || page2.PolicyNames[0] != "SecondPolicy" || page2.IsTruncated { + t.Fatalf("ListRolePolicies page2 = %#v, want final SecondPolicy page", page2) + } + if _, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "missing-role"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("ListRolePolicies missing role err = %v, want NoSuchEntity", err) + } + + // A role with attached inline policies cannot be deleted until they are + // all removed first. + if err := store.DeleteRole(ctx, "alice-role"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) { + t.Fatalf("DeleteRole with policies err = %v, want DeleteConflict", err) + } + + if err := store.DeleteRolePolicy(ctx, "alice-role", "SecondPolicy"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := store.DeleteRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("DeleteRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if err := store.DeleteRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("DeleteRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + if _, err := reopened.GetRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("GetRolePolicy after reopen: %v", err) + } + + if err := reopened.DeleteRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := reopened.DeleteRole(ctx, "alice-role"); err != nil { + t.Fatalf("DeleteRole after removing all policies: %v", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index d54c97a2..9c914d85 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -940,6 +940,122 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss return s.replaceRole(ctx, *role) } +func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return nil, err + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolePoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + // replaceRole overwrites the stored document for role.RoleName by deleting // all existing versions and recreating with CAS=0. func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go index a7149e81..863084ec 100644 --- a/iamapi/types/policy.go +++ b/iamapi/types/policy.go @@ -94,3 +94,53 @@ type ListUserPoliciesResult struct { type PolicyNameList struct { Members []string `xml:"member"` } + +type PutRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRolePolicyResponse"` + Result GetRolePolicyResult `xml:"GetRolePolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResult struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolePoliciesResponse"` + Result ListRolePoliciesResult `xml:"ListRolePoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolePoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolePoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 77a58301..5d08f875 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1322,6 +1322,7 @@ func TestIAMDeleteRole(ts *TestState) { ts.Run(IAMDeleteRole_invalid_role_name) ts.Run(IAMDeleteRole_long_role_name) ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_has_policies) ts.Run(IAMDeleteRole_success) } @@ -1337,6 +1338,47 @@ func TestIAMUpdateAssumeRolePolicy(ts *TestState) { ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) } +func TestIAMPutRolePolicy(ts *TestState) { + ts.Run(IAMPutRolePolicy_missing_role_name) + ts.Run(IAMPutRolePolicy_missing_policy_name) + ts.Run(IAMPutRolePolicy_missing_policy_document) + ts.Run(IAMPutRolePolicy_invalid_policy_name) + ts.Run(IAMPutRolePolicy_long_policy_name) + ts.Run(IAMPutRolePolicy_non_ascii_policy_document) + ts.Run(IAMPutRolePolicy_non_existing_role) + ts.Run(IAMPutRolePolicy_malformed_policy_document) + ts.Run(IAMPutRolePolicy_principal_not_allowed) + ts.Run(IAMPutRolePolicy_limit_exceeded) + ts.Run(IAMPutRolePolicy_success) + ts.Run(IAMPutRolePolicy_overwrite_updates_existing) +} + +func TestIAMGetRolePolicy(ts *TestState) { + ts.Run(IAMGetRolePolicy_missing_role_name) + ts.Run(IAMGetRolePolicy_missing_policy_name) + ts.Run(IAMGetRolePolicy_non_existing_role) + ts.Run(IAMGetRolePolicy_non_existing_policy) + ts.Run(IAMGetRolePolicy_success) +} + +func TestIAMDeleteRolePolicy(ts *TestState) { + ts.Run(IAMDeleteRolePolicy_missing_role_name) + ts.Run(IAMDeleteRolePolicy_missing_policy_name) + ts.Run(IAMDeleteRolePolicy_non_existing_role) + ts.Run(IAMDeleteRolePolicy_non_existing_policy) + ts.Run(IAMDeleteRolePolicy_success) + ts.Run(IAMDeleteRolePolicy_blocks_role_deletion) +} + +func TestIAMListRolePolicies(ts *TestState) { + ts.Run(IAMListRolePolicies_missing_role_name) + ts.Run(IAMListRolePolicies_non_existing_role) + ts.Run(IAMListRolePolicies_invalid_max_items) + ts.Run(IAMListRolePolicies_empty_result) + ts.Run(IAMListRolePolicies_success) + ts.Run(IAMListRolePolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1359,6 +1401,10 @@ func TestIAM(ts *TestState) { TestIAMListRoles(ts) TestIAMDeleteRole(ts) TestIAMUpdateAssumeRolePolicy(ts) + TestIAMPutRolePolicy(ts) + TestIAMGetRolePolicy(ts) + TestIAMDeleteRolePolicy(ts) + TestIAMListRolePolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1854,6 +1900,7 @@ func GetIntTests() IntTests { "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, "IAMDeleteRole_success": IAMDeleteRole_success, "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, @@ -1864,6 +1911,35 @@ func GetIntTests() IntTests { "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, + "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, + "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, + "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, + "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, + "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, + "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, + "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, + "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, + "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, + "IAMPutRolePolicy_success": IAMPutRolePolicy_success, + "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, + "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, + "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, + "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, + "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, + "IAMGetRolePolicy_success": IAMGetRolePolicy_success, + "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, + "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, + "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, + "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, + "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, + "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, + "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, + "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, + "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, + "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, + "IAMListRolePolicies_success": IAMListRolePolicies_success, + "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_delete_role.go b/tests/integration/iam_delete_role.go index ecb30031..a0e5c57a 100644 --- a/tests/integration/iam_delete_role.go +++ b/tests/integration/iam_delete_role.go @@ -67,6 +67,39 @@ func IAMDeleteRole_non_existing_role(s *S3Conf) error { }) } +func IAMDeleteRole_has_policies(s *S3Conf) error { + testName := "IAMDeleteRole_has_policies" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + func IAMDeleteRole_success(s *S3Conf) error { testName := "IAMDeleteRole_success" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role_policy.go b/tests/integration/iam_delete_role_policy.go new file mode 100644 index 00000000..b0277cb8 --- /dev/null +++ b/tests/integration/iam_delete_role_policy.go @@ -0,0 +1,212 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMDeleteRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMDeleteRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_success(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteRolePolicy response request id") + } + + _, err = getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRolePolicy(roleName, "p")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_blocks_role_deletion(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_blocks_role_deletion" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + +func deleteIAMRolePolicyRaw(client *iam.Client, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteRolePolicy(ctx, input) +} + +func deleteIAMRolePolicy(client *iam.Client, roleName, policyName string) error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: &policyName}) + return err +} + +// deleteIAMRoleAndPolicies deletes all of the role's inline policies before +// deleting the role, since DeleteRole rejects roles with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMRoleAndPolicies(client *iam.Client, roleName string) error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMRolePolicy(client, roleName, policyName); err != nil { + return err + } + } + return deleteIAMRole(client, roleName) +} diff --git a/tests/integration/iam_get_role_policy.go b/tests/integration/iam_get_role_policy.go new file mode 100644 index 00000000..29ab2826 --- /dev/null +++ b/tests/integration/iam_get_role_policy.go @@ -0,0 +1,171 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMGetRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMGetRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetRolePolicy_success(s *S3Conf) error { + testName := "IAMGetRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetRolePolicy output") + } + if aws.ToString(out.RoleName) != roleName { + return fmt.Errorf("expected role name %q, instead got %q", roleName, aws.ToString(out.RoleName)) + } + if aws.ToString(out.PolicyName) != "ReadOnly" { + return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetRolePolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRolePolicy(client *iam.Client, input *iam.GetRolePolicyInput) (*iam.GetRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRolePolicy(ctx, input) +} diff --git a/tests/integration/iam_list_role_policies.go b/tests/integration/iam_list_role_policies.go new file mode 100644 index 00000000..bbc68da8 --- /dev/null +++ b/tests/integration/iam_list_role_policies.go @@ -0,0 +1,235 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "slices" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListRolePolicies_missing_role_name(s *S3Conf) error { + testName := "IAMListRolePolicies_missing_role_name" + body := []byte("Action=ListRolePolicies&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMListRolePolicies_non_existing_role(s *S3Conf) error { + testName := "IAMListRolePolicies_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMListRolePolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListRolePolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_empty_result(s *S3Conf) error { + testName := "IAMListRolePolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + if len(out.PolicyNames) != 0 { + return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_success(s *S3Conf) error { + testName := "IAMListRolePolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRolePolicies response request id") + } + got := slices.Clone(out.PolicyNames) + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_pagination(s *S3Conf) error { + testName := "IAMListRolePolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1)} + var pages []*iam.ListRolePoliciesOutput + for { + out, err := listIAMRolePolicies(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.PolicyNames) != 1 { + return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, page.PolicyNames...) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMRolePolicies(client *iam.Client, input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRolePolicies(ctx, input) +} diff --git a/tests/integration/iam_put_role_policy.go b/tests/integration/iam_put_role_policy.go new file mode 100644 index 00000000..89ba286d --- /dev/null +++ b/tests/integration/iam_put_role_policy.go @@ -0,0 +1,389 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMPutRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + "PolicyDocument": {validIAMPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMPutRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "PolicyDocument": {validIAMPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyName")) + }) +} + +func IAMPutRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("policyDocument")) + }) +} + +func IAMPutRolePolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutRolePolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMPutRolePolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_malformed_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + cases := []struct { + name string + doc string + wantErr iamerr.APIError + }{ + {"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")}, + } + + for _, c := range cases { + if err := func() error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(c.doc), + }) + if err := checkIAMApiErr(err, c.wantErr); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutRolePolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutRolePolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutRolePolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10500) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("role", roleName, storage.MaxInlinePolicyBytesPerRole)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_success(s *S3Conf) error { + testName := "IAMPutRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + out, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutRolePolicy response request id") + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutRolePolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}` + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != updated { + return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMRolePolicy(client *iam.Client, input *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutRolePolicy(ctx, input) +} From c16730f35516d943bbfc2cfb2ce23144af12834c Mon Sep 17 00:00:00 2001 From: niksis02 Date: Tue, 21 Jul 2026 22:55:49 +0400 Subject: [PATCH 5/7] feat: add IAM OIDC provider CRUD Add support for `CreateOpenIDConnectProvider`, `GetOpenIDConnectProvider`, `ListOpenIDConnectProviders`, `DeleteOpenIDConnectProvider`, `AddClientIDToOpenIDConnectProvider`, `RemoveClientIDFromOpenIDConnectProvider`, and `UpdateOpenIDConnectProviderThumbprint` on both the internal and Vault storage backends, rounding out the standalone IAM service with the same OIDC identity provider management AWS IAM exposes. CreateOpenIDConnectProvider validates the issuer URL, enforces the client ID and per-provider client ID list limits, and accepts an optional ThumbprintList. When the caller omits ThumbprintList, the provider auto-fetches the thumbprint by opening an outbound TLS connection to the issuer URL and hashing its top-level CA certificate, matching real AWS behavior. This auto-fetch is configurable: it can be turned off with the `--disable-oidc-thumbprint-autofetch` CLI flag (or the `VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH` environment variable) for restricted or air-gapped deployments where the IAM server shouldn't make outbound connections, in which case an omitted ThumbprintList is rejected instead. AddClientIDToOpenIDConnectProvider and RemoveClientIDFromOpenIDConnectProvider manage a provider's client ID list, and UpdateOpenIDConnectProviderThumbprint replaces its thumbprint list, all with the same length and format validation applied at creation time. Provider ARNs are derived from the issuer URL, and GetOpenIDConnectProvider and DeleteOpenIDConnectProvider resolve providers by ARN, returning NoSuchEntity when a provider doesn't exist. ListOpenIDConnectProviders returns the full set of stored providers. These actions are wired into the IAM API router and given their own XML response types under iamapi/types, with a new iamapi/internal/iamutil package handling URL validation, thumbprint fetching and normalization, and ARN construction shared across the controller methods. --- cmd/versitygw/iam.go | 67 +-- embedgw/iam.go | 10 + iamapi/controller.go | 198 ++++++- iamapi/controller_test.go | 330 ++++++++++++ iamapi/iamerr/errors.go | 45 ++ iamapi/internal/iamutil/oidc.go | 225 ++++++++ iamapi/internal/iamutil/oidc_thumbprint.go | 127 +++++ .../internal/iamutil/oidc_thumbprint_test.go | 122 +++++ iamapi/router.go | 60 ++- iamapi/server.go | 13 + iamapi/storage/internal.go | 190 +++++++ iamapi/storage/storer.go | 21 + iamapi/storage/vault.go | 292 +++++++++++ iamapi/types/oidc.go | 129 +++++ tests/integration/group-tests.go | 114 +++++ .../iam_add_client_id_to_oidc_provider.go | 204 ++++++++ tests/integration/iam_create_oidc_provider.go | 481 ++++++++++++++++++ tests/integration/iam_delete_oidc_provider.go | 84 +++ tests/integration/iam_get_oidc_provider.go | 143 ++++++ tests/integration/iam_list_oidc_providers.go | 122 +++++ ...iam_remove_client_id_from_oidc_provider.go | 163 ++++++ .../iam_update_oidc_provider_thumbprint.go | 185 +++++++ 22 files changed, 3269 insertions(+), 56 deletions(-) create mode 100644 iamapi/internal/iamutil/oidc.go create mode 100644 iamapi/internal/iamutil/oidc_thumbprint.go create mode 100644 iamapi/internal/iamutil/oidc_thumbprint_test.go create mode 100644 iamapi/types/oidc.go create mode 100644 tests/integration/iam_add_client_id_to_oidc_provider.go create mode 100644 tests/integration/iam_create_oidc_provider.go create mode 100644 tests/integration/iam_delete_oidc_provider.go create mode 100644 tests/integration/iam_get_oidc_provider.go create mode 100644 tests/integration/iam_list_oidc_providers.go create mode 100644 tests/integration/iam_remove_client_id_from_oidc_provider.go create mode 100644 tests/integration/iam_update_oidc_provider_thumbprint.go diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index 0b5aa8a2..110446bd 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -37,6 +37,8 @@ var ( iamServerVaultServerCert string iamServerVaultClientCert string iamServerVaultClientCertKey string + + iamServerDisableOIDCThumbprintAutoFetch bool ) func iamCommand() *cli.Command { @@ -137,6 +139,12 @@ func iamCommand() *cli.Command { Destination: &quiet, Aliases: []string{"q"}, }, + &cli.BoolFlag{ + Name: "disable-oidc-thumbprint-autofetch", + Usage: "reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection", + EnvVars: []string{"VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH"}, + Destination: &iamServerDisableOIDCThumbprintAutoFetch, + }, }, } } @@ -152,34 +160,35 @@ func runIAM(ctx *cli.Context) error { } 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, + 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, + DisableOIDCThumbprintAutoFetch: iamServerDisableOIDCThumbprintAutoFetch, + Version: Version, + Build: Build, + BuildTime: BuildTime, }) } diff --git a/embedgw/iam.go b/embedgw/iam.go index 959217c7..f6f087ef 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -120,6 +120,13 @@ type IAMConfig struct { Version string Build string BuildTime string + + // DisableOIDCThumbprintAutoFetch disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback for when ThumbprintList is omitted. When set, + // an omitted ThumbprintList is rejected instead of the IAM API making an + // outbound TLS connection to the caller-supplied URL — for restricted + // or air-gapped deployments. + DisableOIDCThumbprintAutoFetch bool } var iamAPIRunning atomic.Bool @@ -198,6 +205,9 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.Quiet { opts = append(opts, iamapi.WithQuiet()) } + if cfg.DisableOIDCThumbprintAutoFetch { + opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) + } if cfg.Debug { debuglogger.SetDebugEnabled() } diff --git a/iamapi/controller.go b/iamapi/controller.go index 0cd44868..0fc3a5a3 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -30,10 +30,19 @@ import ( type IAMApiController struct { store storage.Storer + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback when ThumbprintList is omitted (operational + // safety valve for restricted/air-gapped deployments); set via + // iamapi.WithOIDCThumbprintAutoFetchDisabled(). Defaults to false + // (auto-fetch enabled), matching real AWS behavior. + oidcThumbprintAutoFetchDisabled bool } -func NewController(store storage.Storer) IAMApiController { - return IAMApiController{store: store} +func NewController(store storage.Storer, oidcThumbprintAutoFetchDisabled bool) IAMApiController { + return IAMApiController{ + store: store, + oidcThumbprintAutoFetchDisabled: oidcThumbprintAutoFetchDisabled, + } } func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { @@ -848,3 +857,188 @@ func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + rawURL, ok := iamutil.RequestParam(ctx, "Url") + if !ok || rawURL == "" { + debuglogger.Logf("missing required CreateOpenIDConnectProvider parameter: Url") + return nil, iamerr.MissingValue("url") + } + url, err := iamutil.ValidateOIDCProviderURL(rawURL) + if err != nil { + return nil, err + } + + clientIDs := iamutil.ParseStringList(ctx, "ClientIDList") + if len(clientIDs) > storage.MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider) + } + for _, id := range clientIDs { + if len(id) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if len(thumbprints) == 0 { + if c.oidcThumbprintAutoFetchDisabled { + debuglogger.Logf("CreateOpenIDConnectProvider: ThumbprintList omitted and auto-fetch is disabled") + return nil, iamerr.MissingValue("thumbprintList") + } + fetched, err := iamutil.FetchThumbprint(ctx.Context(), url) + if err != nil { + debuglogger.Logf("failed to auto-fetch OIDC thumbprint for url %q: %v", url, err) + return nil, err + } + thumbprints = []string{fetched} + } else { + if err := iamutil.ValidateThumbprintList(thumbprints, false); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + provider := types.OIDCProvider{ + Arn: iamutil.BuildOIDCProviderArn(iamutil.DefaultAccountID, url), + Url: url, + ClientIDList: clientIDs, + ThumbprintList: thumbprints, + CreateDate: time.Now().UTC().Truncate(time.Second), + Tags: tags, + } + + stored, err := c.store.CreateOIDCProvider(ctx.Context(), provider) + if err != nil { + debuglogger.Logf("failed to create IAM OIDC provider for url %q: %v", url, err) + return nil, err + } + + return &Response{Data: &types.CreateOpenIDConnectProviderResponse{ + Result: types.CreateOpenIDConnectProviderResult{ + OpenIDConnectProviderArn: stored.Arn, + Tags: stored.Tags, + }, + }}, nil +} + +func (c IAMApiController) GetOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "GetOpenIDConnectProvider") + if err != nil { + return nil, err + } + + provider, err := c.store.GetOIDCProvider(ctx.Context(), arn) + if err != nil { + debuglogger.Logf("failed to get IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.GetOpenIDConnectProviderResponse{ + Result: types.GetOpenIDConnectProviderResult{ + Url: provider.Url, + ClientIDList: provider.ClientIDList, + ThumbprintList: provider.ThumbprintList, + CreateDate: provider.CreateDate, + Tags: provider.Tags, + }, + }}, nil +} + +func (c IAMApiController) ListOpenIDConnectProviders(ctx fiber.Ctx) (*Response, error) { + out, err := c.store.ListOIDCProviders(ctx.Context()) + if err != nil { + debuglogger.Logf("failed to list IAM OIDC providers: %v", err) + return nil, err + } + + return &Response{Data: &types.ListOpenIDConnectProvidersResponse{ + Result: types.ListOpenIDConnectProvidersResult{ + OpenIDConnectProviderList: types.OpenIDConnectProviderList{Members: out.Providers}, + }, + }}, nil +} + +func (c IAMApiController) DeleteOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "DeleteOpenIDConnectProvider") + if err != nil { + return nil, err + } + + if err := c.store.DeleteOIDCProvider(ctx.Context(), arn); err != nil { + debuglogger.Logf("failed to delete IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.DeleteOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) AddClientIDToOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "AddClientIDToOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required AddClientIDToOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.AddClientIDToOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to add client id %q to IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.AddClientIDToOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) RemoveClientIDFromOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "RemoveClientIDFromOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required RemoveClientIDFromOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.RemoveClientIDFromOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to remove client id %q from IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.RemoveClientIDFromOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "UpdateOpenIDConnectProviderThumbprint") + if err != nil { + return nil, err + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if err := iamutil.ValidateThumbprintList(thumbprints, true); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + + if err := c.store.UpdateOIDCProviderThumbprint(ctx.Context(), arn, thumbprints); err != nil { + debuglogger.Logf("failed to update IAM OIDC provider thumbprint for %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 3c0cefa8..0f80d9d1 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -18,6 +18,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "testing" "time" @@ -1671,6 +1672,335 @@ func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") } +func TestIAMApiControllerOIDCProviderLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ClientIDList.member.1": {"sts.amazonaws.com"}, + "ThumbprintList.member.1": {"6938FD4D98BAB03FAADB97B34396831E3780AEA1"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateOpenIDConnectProviderResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateOpenIDConnectProviderResponse" { + t.Fatalf("CreateOpenIDConnectProvider XMLName = %#v", createOut.XMLName) + } + wantArn := "arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com" + if createOut.Result.OpenIDConnectProviderArn != wantArn { + t.Fatalf("OpenIDConnectProviderArn = %q, want %q", createOut.Result.OpenIDConnectProviderArn, wantArn) + } + if len(createOut.Result.Tags) != 1 || createOut.Result.Tags[0].Key != "env" || createOut.Result.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", createOut.Result.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateOpenIDConnectProvider missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ThumbprintList.member.1": {"6938fd4d98bab03faadb97b34396831e3780aea1"}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", + "Provider with url https://token.actions.githubusercontent.com already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetOpenIDConnectProvider status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.Url != "token.actions.githubusercontent.com" { + t.Fatalf("Url = %q, want scheme stripped", getOut.Result.Url) + } + if len(getOut.Result.ClientIDList) != 1 || getOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList = %#v", getOut.Result.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(getOut.Result.ThumbprintList) != 1 || getOut.Result.ThumbprintList[0] != "6938fd4d98bab03faadb97b34396831e3780aea1" { + t.Fatalf("ThumbprintList = %#v, want lowercased", getOut.Result.ThumbprintList) + } + if getOut.Result.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + + list := doIAMAction(t, server, url.Values{"Action": {"ListOpenIDConnectProviders"}}) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListOpenIDConnectProviders status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListOpenIDConnectProvidersResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.OpenIDConnectProviderList.Members) != 1 || listOut.Result.OpenIDConnectProviderList.Members[0].Arn != wantArn { + t.Fatalf("ListOpenIDConnectProviders = %#v, want [%s]", listOut.Result.OpenIDConnectProviderList.Members, wantArn) + } + + addClientID := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addClientID.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider status = %d, body=%s", addClientID.StatusCode, readBody(t, addClientID)) + } + + // Idempotent: adding an already-present client ID succeeds silently. + addDuplicate := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addDuplicate.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider (duplicate) status = %d, body=%s", addDuplicate.StatusCode, readBody(t, addDuplicate)) + } + + removeClientID := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if removeClientID.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider status = %d, body=%s", removeClientID.StatusCode, readBody(t, removeClientID)) + } + + // Idempotent: removing an absent client ID succeeds silently. + removeAbsent := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"never-existed"}, + }) + if removeAbsent.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider (absent) status = %d, body=%s", removeAbsent.StatusCode, readBody(t, removeAbsent)) + } + + getAfterClientIDChanges := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterClientIDOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterClientIDChanges), &getAfterClientIDOut) + if len(getAfterClientIDOut.Result.ClientIDList) != 1 || getAfterClientIDOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList after add+remove = %#v, want [sts.amazonaws.com]", getAfterClientIDOut.Result.ClientIDList) + } + + updateThumbprint := doIAMAction(t, server, url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "OpenIDConnectProviderArn": {wantArn}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "ThumbprintList.member.2": {strings.Repeat("B", 40)}, + }) + if updateThumbprint.StatusCode != http.StatusOK { + t.Fatalf("UpdateOpenIDConnectProviderThumbprint status = %d, body=%s", updateThumbprint.StatusCode, readBody(t, updateThumbprint)) + } + + getAfterThumbprintUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterThumbprintOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterThumbprintUpdate), &getAfterThumbprintOut) + wantThumbprints := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) { + t.Fatalf("ThumbprintList after update = %#v, want %#v (full replace, lowercased)", getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteOpenIDConnectProvider status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + + // DeleteOpenIDConnectProvider is NOT idempotent, contradicting AWS's own + // published docs - a second delete of the same ARN must fail. + deleteAgain := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, deleteAgain, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenId connect Provider "+wantArn+" cannot be found.") + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenIDConnect Provider not found for arn "+wantArn) +} + +func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing url", + params: url.Values{"Action": {"CreateOpenIDConnectProvider"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must not be null", + }, + { + name: "no scheme at all", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"example.com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "Invalid Open ID Connect Provider URL", + }, + { + name: "wrong scheme", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"http://example.com"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL. The URL must begin with https://.", + }, + { + name: "query params", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com?foo=1"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "explicit port", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com:8443"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "url too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://" + strings.Repeat("a", 250) + ".com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "client id too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ClientIDList.member.1": {strings.Repeat("c", 256)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'clientID' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "thumbprint wrong length", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 39)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint must be exactly 40 characters.", + }, + { + name: "thumbprint too many", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("1", 40)}, + "ThumbprintList.member.2": {strings.Repeat("2", 40)}, + "ThumbprintList.member.3": {strings.Repeat("3", 40)}, + "ThumbprintList.member.4": {strings.Repeat("4", 40)}, + "ThumbprintList.member.5": {strings.Repeat("5", 40)}, + "ThumbprintList.member.6": {strings.Repeat("6", 40)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint list must contain fewer than 5 entries.", + }, + { + name: "duplicate tag keys", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "Tags.member.1.Key": {"key"}, + "Tags.member.1.Value": {"one"}, + "Tags.member.2.Key": {"KEY"}, + "Tags.member.2.Value": {"two"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) { + store, err := storage.New(storage.Config{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot), WithOIDCThumbprintAutoFetchDisabled()) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must not be null") +} + +// TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard confirms the +// auto-fetch fallback's SSRF guard is wired all the way through the HTTP +// action handler: an omitted ThumbprintList against a loopback URL must be +// rejected before any real network attempt, deterministically and without +// requiring outbound network access from the test environment. +func TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://127.0.0.1"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "OpenIdIdpCommunicationError", + "Could not connect to https://127.0.0.1") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 1df3877e..14d24010 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -425,6 +425,10 @@ func ValueTooLong(field string, maxLength int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength)) } +func ValueTooShort(field string, minLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length greater than or equal to %d", field, minLength)) +} + func InvalidCharset(field string) Error { return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) } @@ -461,6 +465,47 @@ func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Erro return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) } +func EntityAlreadyExistsOIDCProvider(url string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Provider with url %s already exists.", url), http.StatusConflict) +} + +func NoSuchEntityOIDCProviderGet(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenIDConnect Provider not found for arn %s", arn), http.StatusNotFound) +} + +func NoSuchEntityOIDCProviderDelete(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenId connect Provider %s cannot be found.", arn), http.StatusNotFound) +} + +// AccessDeniedOIDCProvider is returned when a well-formed OIDC provider ARN +// references an account id other than callerAccountID. +func AccessDeniedOIDCProvider(callerAccountID, resourceArn string) Error { + return newSenderError("AccessDenied", fmt.Sprintf( + "User: arn:aws:iam::%s:root is not authorized to perform this action on resource: %s", + callerAccountID, resourceArn, + ), http.StatusForbidden) +} + +func ClientIdsPerOpenIdConnectProviderLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: %d", max), http.StatusConflict) +} + +func ThumbprintListTooLong(max int) Error { + return newSenderError("InvalidInput", fmt.Sprintf("Thumbprint list must contain fewer than %d entries.", max), http.StatusBadRequest) +} + +func ThumbprintListEmpty() Error { + return newSenderError("InvalidInput", "Thumbprint list must contain at least one entry.", http.StatusBadRequest) +} + +func OIDCProvidersPerAccountLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for OpenIDConnectProvidersPerAccount: %d", max), http.StatusConflict) +} + +func OpenIdIdpCommunicationError(url string) Error { + return newSenderError("OpenIdIdpCommunicationError", fmt.Sprintf("Could not connect to %s", url), http.StatusBadRequest) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/oidc.go b/iamapi/internal/iamutil/oidc.go new file mode 100644 index 00000000..36f9e365 --- /dev/null +++ b/iamapi/internal/iamutil/oidc.go @@ -0,0 +1,225 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const ( + MinOIDCProviderArnLen = 20 + MaxOIDCProviderArnLen = 2048 + MaxOIDCProviderURLLen = 255 + MaxOIDCClientIDLen = 255 + MaxThumbprintsPerOIDCProvider = 5 + OIDCThumbprintLen = 40 + + oidcProviderResourceType = "oidc-provider" +) + +var oidcHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + +// ParseStringList reads flat indexed list members ".member.1", +// ".member.2", ... — the AWS Query-protocol wire form for a bare +// []string (distinct from ParseTags's Key/Value-pair member form, used by +// ClientIDList/ThumbprintList) — stopping at the first missing index. +// Returns nil if no entries are present. +func ParseStringList(ctx fiber.Ctx, paramName string) []string { + var values []string + for i := 1; ; i++ { + value, ok := RequestParam(ctx, fmt.Sprintf("%s.member.%d", paramName, i)) + if !ok { + break + } + values = append(values, value) + } + return values +} + +// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity +// provider. url must already have its "https://" scheme stripped. +func BuildOIDCProviderArn(accountID, url string) string { + return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, url) +} + +// ParseOIDCProviderArn validates arn's overall length and structural shape +// (arn:aws:iam:::/) and, on success, +// returns the resource segment — the provider's Url with "https://" already +// stripped, exactly as stored. The account-id segment must match +// DefaultAccountID; any other value is rejected with AccessDenied, matching +// real AWS's behavior for a well-formed ARN referencing a foreign account. +// +// Beyond the length and account-id checks, real AWS produces several more +// specific messages for structurally-malformed ARNs this function does not +// reproduce byte-for-byte — e.g. "Invalid service in ARN" for a non-iam +// service segment (a check this function does not perform at all), and a +// bare "Invalid ARN" (no echoed value) for a present-but-empty resource — +// this function falls back to a generic "Invalid ARN: %s" for those cases +// instead. +func ParseOIDCProviderArn(arn string) (string, error) { + if len(arn) < MinOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooShort("openIDConnectProviderArn", MinOIDCProviderArnLen) + } + if len(arn) > MaxOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooLong("openIDConnectProviderArn", MaxOIDCProviderArnLen) + } + + const prefix = "arn:aws:iam::" + if !strings.HasPrefix(arn, prefix) { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + + rest := strings.SplitN(arn[len(prefix):], ":", 2) + if len(rest) != 2 || rest[0] == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if rest[0] != DefaultAccountID { + debuglogger.Logf("OpenIDConnectProviderArn account id mismatch: %q", arn) + return "", iamerr.AccessDeniedOIDCProvider(DefaultAccountID, arn) + } + + resourceType, resource, ok := strings.Cut(rest[1], "/") + if !ok || resource == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if resourceType != oidcProviderResourceType { + debuglogger.Logf("wrong resource type in ARN: %q", arn) + return "", iamerr.ValidationError("Invalid resource type in ARN") + } + + return resource, nil +} + +// GetOIDCProviderArn resolves the OpenIDConnectProviderArn request +// parameter, validates its shape via ParseOIDCProviderArn, and returns the +// ARN exactly as supplied by the caller (used verbatim in NoSuchEntity +// messages, which echo the full ARN, not just the url). A missing +// parameter is rejected with iamerr.MissingValue — every OIDC action +// taking this parameter reports it identically. +func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) { + arn, ok := RequestParam(ctx, "OpenIDConnectProviderArn") + if !ok || arn == "" { + debuglogger.Logf("missing required %s parameter: OpenIDConnectProviderArn", operation) + return "", iamerr.MissingValue("openIDConnectProviderArn") + } + if _, err := ParseOIDCProviderArn(arn); err != nil { + return "", err + } + return arn, nil +} + +// ValidateOIDCProviderURL validates the Url parameter of +// CreateOpenIDConnectProvider and returns it with its "https://" scheme +// stripped (the canonical form used for ARN construction, storage keys, and +// GetOpenIDConnectProvider's own Url response field). +// +// This implements a pragmatic subset of AWS's real validation: scheme must +// be exactly "https", no userinfo/port/query/fragment, host must be a +// syntactically plausible RFC-1123-ish hostname or IP literal, overall +// length <= MaxOIDCProviderURLLen. It does not attempt to reproduce every +// hostname-shape check AWS performs; it returns clear InvalidInput/ +// ValidationError messages instead of chasing every malformed edge case. +func ValidateOIDCProviderURL(rawURL string) (string, error) { + if rawURL == "" { + return "", iamerr.MissingValue("url") + } + if len(rawURL) > MaxOIDCProviderURLLen { + return "", iamerr.ValueTooLong("url", MaxOIDCProviderURLLen) + } + // A URL with no scheme delimiter at all (e.g. "example.com") is + // rejected as ValidationError; one with a scheme other than https + // (e.g. "http://example.com") is rejected as InvalidInput — distinct + // error codes for distinct malformed inputs. + if !strings.Contains(rawURL, "://") { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if !strings.HasPrefix(rawURL, "https://") { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.") + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Port() != "" { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + if !isValidOIDCHostname(parsed.Hostname()) { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + + return strings.TrimPrefix(rawURL, "https://"), nil +} + +func isValidOIDCHostname(host string) bool { + if net.ParseIP(host) != nil { + return true + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if !oidcHostLabelPattern.MatchString(label) { + return false + } + } + return true +} + +// ValidateThumbprintList validates a parsed ThumbprintList: at most +// MaxThumbprintsPerOIDCProvider entries, each exactly OIDCThumbprintLen +// characters (no hex-charset check — any 40-char string is accepted). If +// required is true, an empty list is rejected +// (UpdateOpenIDConnectProviderThumbprint, no auto-fetch fallback exists +// there); if false, an empty list passes through untouched +// (CreateOpenIDConnectProvider, whose caller handles empty via auto-fetch +// before calling this). +func ValidateThumbprintList(thumbprints []string, required bool) error { + if required && len(thumbprints) == 0 { + return iamerr.ThumbprintListEmpty() + } + if len(thumbprints) > MaxThumbprintsPerOIDCProvider { + return iamerr.ThumbprintListTooLong(MaxThumbprintsPerOIDCProvider) + } + for _, tp := range thumbprints { + if len(tp) != OIDCThumbprintLen { + return iamerr.InvalidInput(fmt.Sprintf("Thumbprint must be exactly %d characters.", OIDCThumbprintLen)) + } + } + return nil +} + +// NormalizeThumbprintList lowercases every entry: AWS stores/returns +// thumbprints lowercased regardless of submitted case. +func NormalizeThumbprintList(thumbprints []string) []string { + out := make([]string, len(thumbprints)) + for i, tp := range thumbprints { + out[i] = strings.ToLower(tp) + } + return out +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint.go b/iamapi/internal/iamutil/oidc_thumbprint.go new file mode 100644 index 00000000..11ff9881 --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -0,0 +1,127 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "net" + "strings" + "time" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const oidcThumbprintFetchTimeout = 8 * time.Second + +// FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch +// behavior: it opens a raw TLS handshake (crypto/tls, not a full +// HTTP GET) to host:443, where host is derived from providerURL (a +// scheme-stripped OIDC provider Url), and returns the SHA-1 thumbprint of +// the last (top-most/intermediate CA) certificate in the peer's presented +// chain. +// +// SSRF hardening (mandatory): the hostname is resolved once via +// net.DefaultResolver.LookupIP; if any resolved address is +// loopback/private/link-local/unspecified/multicast (this range covers +// 169.254.169.254 and other cloud metadata endpoints), the fetch is +// rejected before any connection attempt. The TLS dial then targets one of +// the pre-validated IPs directly (never re-resolving the hostname at dial +// time, closing the DNS-rebinding TOCTOU gap) while presenting the original +// hostname via tls.Config.ServerName for SNI/certificate purposes. +// +// tls.Config.InsecureSkipVerify is deliberately set: this handshake exists +// solely to observe whatever certificate chain the peer presents — that is +// the entire point of AWS's thumbprint-pinning feature (trusting an +// operator-established fingerprint for IDPs whose certs may not pass +// standard verification). No application data is sent or received over +// this connection, so skipping chain verification does not expose any real +// traffic to a MITM. +func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { + host := hostFromOIDCUrl(providerURL) + displayURL := "https://" + providerURL + + ctx, cancel := context.WithTimeout(ctx, oidcThumbprintFetchTimeout) + defer cancel() + + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + debuglogger.Logf("oidc thumbprint fetch: dns lookup failed for %q: %v", host, err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + for _, ip := range ips { + if isDisallowedFetchTarget(ip) { + debuglogger.Logf("oidc thumbprint fetch: refusing to dial disallowed address %q for host %q", ip, host) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + } + + dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), "443")) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: tls dial failed for %q (%s): %v", host, ips[0], err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + + thumbprint, err := ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: %v", err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + return thumbprint, nil +} + +// ThumbprintFromChain computes AWS's documented OIDC thumbprint: the SHA-1 +// hash of the DER bytes of the last (top-most/intermediate CA) certificate +// in chain, hex-encoded and lowercased. Split out from FetchThumbprint as a +// pure function specifically so it is unit-testable (e.g. against a chain +// obtained from httptest.NewTLSServer) without going through +// FetchThumbprint's SSRF guard, which must always reject loopback targets +// and therefore can never itself be exercised against a same-process test +// server. +func ThumbprintFromChain(chain []*x509.Certificate) (string, error) { + if len(chain) == 0 { + return "", errors.New("iamutil: empty certificate chain") + } + top := chain[len(chain)-1] + sum := sha1.Sum(top.Raw) + return hex.EncodeToString(sum[:]), nil +} + +func isDisallowedFetchTarget(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() +} + +// hostFromOIDCUrl extracts the host (no scheme, no path — OIDC provider +// URLs are validated to disallow explicit ports) from a scheme-stripped +// provider Url. +func hostFromOIDCUrl(providerURL string) string { + if before, _, ok := strings.Cut(providerURL, "/"); ok { + return before + } + return providerURL +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint_test.go b/iamapi/internal/iamutil/oidc_thumbprint_test.go new file mode 100644 index 00000000..39d65a9c --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/sha1" + "crypto/tls" + "encoding/hex" + "net" + "net/http/httptest" + "testing" +) + +// TestThumbprintFromChain exercises the pure cert-chain-hashing logic +// (AWS's OIDC thumbprint is the SHA-1 hash of the DER bytes of the +// last/top-most certificate in the peer's presented chain, hex encoded and +// lowercased) against a real TLS handshake with a locally generated +// self-signed certificate. +// +// This deliberately dials httptest.NewTLSServer directly with tls.Dial +// rather than going through FetchThumbprint, whose SSRF guard must always +// reject loopback targets — exactly what a local test server is. +func TestThumbprintFromChain(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + conn, err := tls.Dial("tcp", srv.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + + chain := conn.ConnectionState().PeerCertificates + if len(chain) == 0 { + t.Fatal("expected at least one peer certificate") + } + + got, err := ThumbprintFromChain(chain) + if err != nil { + t.Fatalf("ThumbprintFromChain: %v", err) + } + + sum := sha1.Sum(chain[len(chain)-1].Raw) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Fatalf("ThumbprintFromChain = %q, want %q", got, want) + } + if len(got) != OIDCThumbprintLen { + t.Fatalf("thumbprint length = %d, want %d", len(got), OIDCThumbprintLen) + } +} + +func TestThumbprintFromChainEmptyChain(t *testing.T) { + if _, err := ThumbprintFromChain(nil); err == nil { + t.Fatal("expected error for empty certificate chain") + } +} + +// TestFetchThumbprintSSRFGuard confirms FetchThumbprint refuses to dial +// loopback/private targets before any network attempt, matching the +// mandatory SSRF hardening design: 127.0.0.1 is exactly the kind of +// address a malicious CreateOpenIDConnectProvider caller could supply to +// probe the gateway's own local network. +func TestFetchThumbprintSSRFGuard(t *testing.T) { + tests := []string{ + "127.0.0.1", + "169.254.169.254", // cloud metadata endpoint + "::1", + } + for _, host := range tests { + t.Run(host, func(t *testing.T) { + _, err := FetchThumbprint(context.Background(), host) + if err == nil { + t.Fatalf("FetchThumbprint(%q): expected SSRF guard error, got nil", host) + } + }) + } +} + +func TestFetchThumbprintDNSFailure(t *testing.T) { + _, err := FetchThumbprint(context.Background(), "this-host-should-not-resolve.invalid") + if err == nil { + t.Fatal("expected error for unresolvable host") + } +} + +func TestIsDisallowedFetchTarget(t *testing.T) { + tests := []struct { + ip string + disallowed bool + }{ + {"127.0.0.1", true}, + {"169.254.169.254", true}, + {"10.0.0.5", true}, + {"192.168.1.1", true}, + {"::1", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + } + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("invalid test IP %q", tt.ip) + } + if got := isDisallowedFetchTarget(ip); got != tt.disallowed { + t.Errorf("isDisallowedFetchTarget(%q) = %v, want %v", tt.ip, got, tt.disallowed) + } + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ddb39bd2..430398fb 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -38,41 +38,51 @@ type IAMApiRouter struct { Ctrl IAMApiController actions map[string]ActionHandler rootCreds *RootCredentials + // oidcThumbprintAutoFetchDisabled is threaded into the controller; + // see IAMApiController.oidcThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func (r *IAMApiRouter) Init() { - ctrl := NewController(r.store) - r.Ctrl = ctrl + r.Ctrl = NewController(r.store, r.oidcThumbprintAutoFetchDisabled) r.actions = map[string]ActionHandler{ // User CRUD - "CreateUser": ctrl.CreateUser, - "DeleteUser": ctrl.DeleteUser, - "GetUser": ctrl.GetUser, - "ListUsers": ctrl.ListUsers, - "UpdateUser": ctrl.UpdateUser, + "CreateUser": r.Ctrl.CreateUser, + "DeleteUser": r.Ctrl.DeleteUser, + "GetUser": r.Ctrl.GetUser, + "ListUsers": r.Ctrl.ListUsers, + "UpdateUser": r.Ctrl.UpdateUser, // User Access Key CRUD - "CreateAccessKey": ctrl.CreateAccessKey, - "UpdateAccessKey": ctrl.UpdateAccessKey, - "DeleteAccessKey": ctrl.DeleteAccessKey, - "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, - "ListAccessKeys": ctrl.ListAccessKeys, + "CreateAccessKey": r.Ctrl.CreateAccessKey, + "UpdateAccessKey": r.Ctrl.UpdateAccessKey, + "DeleteAccessKey": r.Ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": r.Ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": r.Ctrl.ListAccessKeys, // User Inline Policy CRUD - "PutUserPolicy": ctrl.PutUserPolicy, - "GetUserPolicy": ctrl.GetUserPolicy, - "DeleteUserPolicy": ctrl.DeleteUserPolicy, - "ListUserPolicies": ctrl.ListUserPolicies, + "PutUserPolicy": r.Ctrl.PutUserPolicy, + "GetUserPolicy": r.Ctrl.GetUserPolicy, + "DeleteUserPolicy": r.Ctrl.DeleteUserPolicy, + "ListUserPolicies": r.Ctrl.ListUserPolicies, // Role CRUD - "CreateRole": ctrl.CreateRole, - "GetRole": ctrl.GetRole, - "ListRoles": ctrl.ListRoles, - "DeleteRole": ctrl.DeleteRole, - "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + "CreateRole": r.Ctrl.CreateRole, + "GetRole": r.Ctrl.GetRole, + "ListRoles": r.Ctrl.ListRoles, + "DeleteRole": r.Ctrl.DeleteRole, + "UpdateAssumeRolePolicy": r.Ctrl.UpdateAssumeRolePolicy, // Role Inline Policy CRUD - "PutRolePolicy": ctrl.PutRolePolicy, - "GetRolePolicy": ctrl.GetRolePolicy, - "DeleteRolePolicy": ctrl.DeleteRolePolicy, - "ListRolePolicies": ctrl.ListRolePolicies, + "PutRolePolicy": r.Ctrl.PutRolePolicy, + "GetRolePolicy": r.Ctrl.GetRolePolicy, + "DeleteRolePolicy": r.Ctrl.DeleteRolePolicy, + "ListRolePolicies": r.Ctrl.ListRolePolicies, + // OIDC Provider CRUD + "CreateOpenIDConnectProvider": r.Ctrl.CreateOpenIDConnectProvider, + "GetOpenIDConnectProvider": r.Ctrl.GetOpenIDConnectProvider, + "ListOpenIDConnectProviders": r.Ctrl.ListOpenIDConnectProviders, + "DeleteOpenIDConnectProvider": r.Ctrl.DeleteOpenIDConnectProvider, + "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, + "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, + "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/server.go b/iamapi/server.go index c5ccbe47..43660610 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -58,6 +58,9 @@ type IAMApiServer struct { maxRequests int socketPerm os.FileMode onListen func() + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback; see WithOIDCThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { @@ -89,6 +92,7 @@ func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { server.app = app server.Router.app = app server.Router.rootCreds = server.rootCreds + server.Router.oidcThumbprintAutoFetchDisabled = server.oidcThumbprintAutoFetchDisabled app.Use("*", recover.New(recover.Config{ EnableStackTrace: true, @@ -161,6 +165,15 @@ func WithRootUserCreds(root RootCredentials) Option { } } +// WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's +// TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an +// omitted ThumbprintList is rejected with a MissingValue error instead of +// the gateway making an outbound TLS connection to the caller-supplied URL +// — an operational safety valve for restricted/air-gapped deployments. +func WithOIDCThumbprintAutoFetchDisabled() Option { + return func(s *IAMApiServer) { s.oidcThumbprintAutoFetchDisabled = true } +} + func (s *IAMApiServer) ServeMultiPort(ports []string) error { if len(ports) == 0 { return fmt.Errorf("no ports specified") diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 536418d8..70cc7ac1 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -24,6 +24,7 @@ import ( "time" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" "github.com/versity/versitygw/internal/iamstore" ) @@ -63,6 +64,11 @@ type iamConfig struct { Roles map[string]types.Role `json:"roles"` // RoleNameIndex is UserNameIndex's counterpart for roles. RoleNameIndex map[string]string `json:"roleNameIndex"` + + // OIDCProviders is keyed directly by the provider's Url (scheme + // stripped, exactly as given at creation — no index needed since + // lookup is by exact string, not a case-insensitive human name). + OIDCProviders map[string]types.OIDCProvider `json:"oidcProviders"` } func defaultIAMConfig() iamConfig { @@ -72,6 +78,7 @@ func defaultIAMConfig() iamConfig { UserNameIndex: map[string]string{}, Roles: map[string]types.Role{}, RoleNameIndex: map[string]string{}, + OIDCProviders: map[string]types.OIDCProvider{}, } } @@ -104,6 +111,10 @@ func normalizeIAMConfig(conf *iamConfig) { conf.RoleNameIndex[key] = name } } + + if conf.OIDCProviders == nil { + conf.OIDCProviders = make(map[string]types.OIDCProvider) + } } // lookupUser resolves name to the canonical stored user name and entry, @@ -983,3 +994,182 @@ func cloneRole(role types.Role) *types.Role { cloned.Policies.Inline = slices.Clone(role.Policies.Inline) return &cloned } + +func (s *InternalStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) { + s.Lock() + defer s.Unlock() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + if _, ok := conf.OIDCProviders[provider.Url]; ok { + return nil, iamerr.EntityAlreadyExistsOIDCProvider("https://" + provider.Url) + } + if len(conf.OIDCProviders) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + + conf.OIDCProviders[provider.Url] = provider + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + s.RLock() + defer s.RUnlock() + + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(conf.OIDCProviders)) + for _, p := range conf.OIDCProviders { + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: p.Arn}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *InternalStore) DeleteOIDCProvider(_ context.Context, arn string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + if _, ok := conf.OIDCProviders[url]; !ok { + return nil, iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + delete(conf.OIDCProviders, url) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) AddClientIDToOIDCProvider(_ context.Context, arn, clientID string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + if slices.Contains(provider.ClientIDList, clientID) { + return json.Marshal(conf) + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) RemoveClientIDFromOIDCProvider(_ context.Context, arn, clientID string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return json.Marshal(conf) + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn string, thumbprints []string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + provider.ThumbprintList = thumbprints + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func cloneOIDCProvider(p types.OIDCProvider) *types.OIDCProvider { + cloned := p + cloned.ClientIDList = slices.Clone(p.ClientIDList) + cloned.ThumbprintList = slices.Clone(p.ThumbprintList) + cloned.Tags = slices.Clone(p.Tags) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index aa915c19..7f0b9a73 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -37,6 +37,14 @@ const MaxInlinePolicyBytesPerUser = 2048 // all of a single IAM role's inline policy documents combined const MaxInlinePolicyBytesPerRole = 10240 +// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single +// OIDC provider may hold at once +const MaxClientIDsPerOIDCProvider = 100 + +// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a +// single account may hold +const MaxOIDCProvidersPerAccount = 100 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -148,6 +156,10 @@ type ListRolePoliciesOutput struct { Marker string } +type ListOIDCProvidersOutput struct { + Providers []types.OpenIDConnectProviderListEntry +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -177,6 +189,15 @@ type Storer interface { GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) + + // OIDC Provider CRUD + CreateOIDCProvider(ctx context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + ListOIDCProviders(ctx context.Context) (*ListOIDCProvidersOutput, error) + DeleteOIDCProvider(ctx context.Context, arn string) error + AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error + RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error + UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c914d85..5b9d9694 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -16,6 +16,7 @@ package storage import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( vault "github.com/hashicorp/vault-client-go" "github.com/hashicorp/vault-client-go/schema" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" ) @@ -1119,6 +1121,296 @@ func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { return role, nil } +// oidcProvidersPath is the KV prefix under which OIDC providers are stored, +// kept distinct from secretStoragePath/rolesPath. +func (s *VaultStore) oidcProvidersPath() string { + return s.secretStoragePath + "/oidc-providers" +} + +// oidcProviderPathSegment returns the literal KV path segment for a +// provider identified by its scheme-stripped url. OIDC provider URLs may +// themselves contain "/" (e.g. "host/" and "host/path" are distinct valid +// providers) and Vault KV paths treat "/" as a path +// separator, so — unlike RoleName/UserName, which never contain "/" and are +// used as literal path segments directly — the raw url cannot safely be +// used as a KV path segment. base64url-encoding (RawURLEncoding: lossless, +// produces only [A-Za-z0-9_-], no "/" or "=" padding) collapses it to one +// opaque, path-safe segment. The same segment is reused as the single outer +// JSON key inside the KV secret body (a deliberate deviation from +// roleToVaultMap/userToVaultMap's convention of keying on the +// human-readable name — simpler here since only one identifier needs to be +// tracked for read-back, not two). +func oidcProviderPathSegment(url string) string { + return base64.RawURLEncoding.EncodeToString([]byte(url)) +} + +func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) { + segment := oidcProviderPathSegment(provider.Url) + path := s.oidcProvidersPath() + "/" + segment + displayURL := "https://" + provider.Url + + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, err + } + } + if resp != nil { + if slices.Contains(resp.Data.Keys, segment) { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if len(resp.Data.Keys) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + } + + providerMap, err := oidcProviderToVaultMap(provider) + if err != nil { + return nil, fmt.Errorf("serialize oidc provider: %w", err) + } + req := schema.KvV2WriteRequest{ + Data: map[string]any{segment: providerMap}, + Options: map[string]any{"cas": 0}, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if vault.IsErrorStatus(err, http.StatusForbidden) { + return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err) + } + return nil, err + } + } + return cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + segment := oidcProviderPathSegment(url) + path := s.oidcProvidersPath() + "/" + segment + + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return nil, err + } + } + + provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) + if err != nil { + return nil, err + } + return cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + return nil, err + } + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(resp.Data.Keys)) + for _, segment := range resp.Data.Keys { + // Read each secret by its already-known key rather than decoding + // segment back to a url, populating the list from each secret's own + // stored fields. + path := s.oidcProvidersPath() + "/" + segment + secretResp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + secretResp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + return nil, err + } + } + provider, err := parseVaultOIDCProvider(secretResp.Data.Data, segment) + if err != nil { + return nil, err + } + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: provider.Arn}) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *VaultStore) DeleteOIDCProvider(_ context.Context, arn string) error { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return err + } + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + + // Existence check first: unlike deleteRoleByPath (only reached after + // DeleteRole's own prior GetRole existence check), Delete's own + // not-found path is load-bearing here (NOT idempotent). + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + return err + } + } + + return s.deleteOIDCProviderByURL(url) +} + +func (s *VaultStore) deleteOIDCProviderByURL(url string) error { + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + return err + } + } + return nil +} + +// AddClientIDToOIDCProvider / RemoveClientIDFromOIDCProvider / +// UpdateOIDCProviderThumbprint use non-atomic get-then-replace, mirroring +// the existing consistency model of UpdateAssumeRolePolicy/PutRolePolicy's +// Vault implementations — this codebase has no CAS-protected +// read-modify-write for Vault mutations today, and this does not introduce +// one. + +func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + if slices.Contains(provider.ClientIDList, clientID) { + return nil + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return nil + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + provider.ThumbprintList = thumbprints + return s.replaceOIDCProvider(ctx, *provider) +} + +// replaceOIDCProvider overwrites the stored document for provider.Url by +// deleting all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider) error { + if err := s.deleteOIDCProviderByURL(provider.Url); err != nil { + return err + } + _, err := s.CreateOIDCProvider(ctx, provider) + return err +} + +var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine") + +func oidcProviderToVaultMap(provider types.OIDCProvider) (map[string]any, error) { + b, err := json.Marshal(provider) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// parseVaultOIDCProvider reconstructs an OIDCProvider from the raw +// map[string]any vault returns. The outer key is the base64url path +// segment used at write time (oidcProviderPathSegment), not a +// human-readable value — unlike parseVaultRole/parseVaultUser. +func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProvider, error) { + raw, ok := data[segment] + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + providerMap, ok := raw.(map[string]any) + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + b, err := json.Marshal(providerMap) + if err != nil { + return types.OIDCProvider{}, fmt.Errorf("re-marshal vault oidc provider: %w", err) + } + var provider types.OIDCProvider + if err := json.Unmarshal(b, &provider); err != nil { + return types.OIDCProvider{}, fmt.Errorf("unmarshal vault oidc provider: %w", err) + } + return provider, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/oidc.go b/iamapi/types/oidc.go new file mode 100644 index 00000000..d8ee2e84 --- /dev/null +++ b/iamapi/types/oidc.go @@ -0,0 +1,129 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +// OIDCProvider is the storage-layer representation of an IAM OIDC identity +// provider. Unlike Role, it is never marshaled to XML directly — each real +// IAM action returns a different subset of its fields — so it is copied +// field-by-field into the narrower XML result types +type OIDCProvider struct { + // Arn is the full arn:aws:iam:::oidc-provider/ ARN. + Arn string `json:"arn"` + // Url is stored WITHOUT the "https://" scheme prefix. This is both the + // ARN's resource-path suffix and the exact string + // GetOpenIDConnectProvider echoes back in its own Url field. It is never + // case-folded or otherwise normalized + Url string `json:"url"` + ClientIDList []string `json:"clientIDList,omitempty"` + ThumbprintList []string `json:"thumbprintList,omitempty"` + CreateDate time.Time `json:"createDate"` + Tags []Tag `json:"tags,omitempty"` +} + +type CreateOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"` + Result CreateOpenIDConnectProviderResult `xml:"CreateOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateOpenIDConnectProviderResult struct { + OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type GetOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"` + Result GetOpenIDConnectProviderResult `xml:"GetOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetOpenIDConnectProviderResult struct { + Url string `xml:",omitempty"` + ClientIDList []string `xml:"ClientIDList>member,omitempty"` + ThumbprintList []string `xml:"ThumbprintList>member,omitempty"` + CreateDate time.Time `xml:"CreateDate"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type ListOpenIDConnectProvidersResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"` + Result ListOpenIDConnectProvidersResult `xml:"ListOpenIDConnectProvidersResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListOpenIDConnectProvidersResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListOpenIDConnectProvidersResult struct { + OpenIDConnectProviderList OpenIDConnectProviderList +} + +type OpenIDConnectProviderList struct { + Members []OpenIDConnectProviderListEntry `xml:"member"` +} + +type OpenIDConnectProviderListEntry struct { + Arn string `xml:"Arn"` +} + +type DeleteOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type AddClientIDToOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *AddClientIDToOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type RemoveClientIDFromOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *RemoveClientIDFromOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateOpenIDConnectProviderThumbprintResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateOpenIDConnectProviderThumbprintResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 5d08f875..6982aff6 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1379,6 +1379,70 @@ func TestIAMListRolePolicies(ts *TestState) { ts.Run(IAMListRolePolicies_pagination) } +func TestIAMCreateOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMCreateOpenIDConnectProvider_missing_url) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_url) + ts.Run(IAMCreateOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMCreateOpenIDConnectProvider_too_many_client_ids) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_thumbprint) + ts.Run(IAMCreateOpenIDConnectProvider_duplicate_tag_keys) + ts.Run(IAMCreateOpenIDConnectProvider_already_exists) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error) + ts.Run(IAMCreateOpenIDConnectProvider_quota_exceeded) + ts.Run(IAMCreateOpenIDConnectProvider_success) + ts.Run(IAMCreateOpenIDConnectProvider_defaults) + ts.Run(IAMCreateOpenIDConnectProvider_ip_literal_host) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_edge_cases) + ts.Run(IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity) +} + +func TestIAMGetOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMGetOpenIDConnectProvider_missing_arn) + ts.Run(IAMGetOpenIDConnectProvider_invalid_arn) + ts.Run(IAMGetOpenIDConnectProvider_non_existing) + ts.Run(IAMGetOpenIDConnectProvider_success) +} + +func TestIAMListOpenIDConnectProviders(ts *TestState) { + ts.Run(IAMListOpenIDConnectProviders_success) +} + +func TestIAMDeleteOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMDeleteOpenIDConnectProvider_missing_arn) + ts.Run(IAMDeleteOpenIDConnectProvider_non_existing) + ts.Run(IAMDeleteOpenIDConnectProvider_success) + ts.Run(IAMDeleteOpenIDConnectProvider_not_idempotent) +} + +func TestIAMAddClientIDToOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_arn) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_client_id) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_limit_exceeded) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_success) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate) +} + +func TestIAMRemoveClientIDFromOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_success) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent) +} + +func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_arn) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_success) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1405,6 +1469,13 @@ func TestIAM(ts *TestState) { TestIAMGetRolePolicy(ts) TestIAMDeleteRolePolicy(ts) TestIAMListRolePolicies(ts) + TestIAMCreateOpenIDConnectProvider(ts) + TestIAMGetOpenIDConnectProvider(ts) + TestIAMListOpenIDConnectProviders(ts) + TestIAMDeleteOpenIDConnectProvider(ts) + TestIAMAddClientIDToOpenIDConnectProvider(ts) + TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) + TestIAMUpdateOpenIDConnectProviderThumbprint(ts) } func TestAccessControl(ts *TestState) { @@ -1940,6 +2011,49 @@ func GetIntTests() IntTests { "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, "IAMListRolePolicies_success": IAMListRolePolicies_success, "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, + "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, + "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, + "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, + "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, + "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, + "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, + "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, + "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, + "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, + "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, + "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, + "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, + "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, + "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, + "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, + "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, + "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, + "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, + "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, + "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, + "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, + "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, + "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, + "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, + "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, + "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, + "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, + "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, + "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, + "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, + "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, + "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, + "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, + "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, + "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, + "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, + "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, + "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, + "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_add_client_id_to_oidc_provider.go b/tests/integration/iam_add_client_id_to_oidc_provider.go new file mode 100644 index 00000000..8f6bd2e5 --- /dev/null +++ b/tests/integration/iam_add_client_id_to_oidc_provider.go @@ -0,0 +1,204 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMAddClientIDToOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(addClientIDToOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_limit_exceeded(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := checkIAMApiErr( + addClientIDToOIDCProvider(client, arn, "one-too-many"), + iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate confirms +// adding an already-present client ID succeeds silently rather than +// erroring or creating a duplicate entry. +func IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com] (no duplicate), instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func addClientIDToOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.AddClientIDToOpenIDConnectProvider(ctx, &iam.AddClientIDToOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_create_oidc_provider.go b/tests/integration/iam_create_oidc_provider.go new file mode 100644 index 00000000..66832078 --- /dev/null +++ b/tests/integration/iam_create_oidc_provider.go @@ -0,0 +1,481 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +// validOIDCThumbprint is a syntactically valid (40 hex chars) thumbprint +// used whenever a test needs a ThumbprintList entry but isn't specifically +// exercising thumbprint validation. +const validOIDCThumbprint = "6938fd4d98bab03faadb97b34396831e3780aea1" + +func IAMCreateOpenIDConnectProvider_missing_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_missing_url" + body := []byte(url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("url")) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_url" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range []struct { + name string + url string + want iamerr.Error + }{ + {"no_scheme", "example.com", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"wrong_scheme", "http://example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.")}, + {"empty_host", "https://", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"userinfo", "https://user:pass@example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"query_params", "https://example.com?foo=1", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"fragment", "https://example.com#frag", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"explicit_port", "https://example.com:8443", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"invalid_hostname_chars", "https://exa_mple.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"too_long", "https://" + strings.Repeat("a", 250) + ".com", iamerr.ValueTooLong("url", 255)}, + } { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(tt.url)}) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{strings.Repeat("c", 256)}, + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("clientID", 255)) + }) +} + +func IAMCreateOpenIDConnectProvider_too_many_client_ids(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_too_many_client_ids" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider+1) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(err, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider)) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_thumbprint(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("a", 39)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.InvalidInput("Thumbprint must be exactly 40 characters.")); checkErr != nil { + return fmt.Errorf("wrong_length: %w", checkErr) + } + + _, err = createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.ThumbprintListTooLong(5)); checkErr != nil { + return fmt.Errorf("too_many: %w", checkErr) + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + Tags: []iamtypes.Tag{ + {Key: aws.String("key"), Value: aws.String("one")}, + {Key: aws.String("KEY"), Value: aws.String("two")}, + }, + }) + return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive.")) + }) +} + +func IAMCreateOpenIDConnectProvider_already_exists(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_already_exists" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + arn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + + _, dupErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + checkErr := checkIAMApiErr(dupErr, iamerr.EntityAlreadyExistsOIDCProvider(providerURL)) + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// confirms the network-dependent auto-fetch fallback (triggered by +// omitting ThumbprintList) is wired all the way through the real HTTP +// action handler: a loopback URL is rejected by the fetch's mandatory +// SSRF guard before any real network attempt, deterministically and +// without requiring outbound network access from the test environment. +func IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://127.0.0.1"), + }) + return checkIAMApiErr(err, iamerr.OpenIdIdpCommunicationError("https://127.0.0.1")) + }) +} + +// IAMCreateOpenIDConnectProvider_quota_exceeded tops the account up to +// storage.MaxOIDCProvidersPerAccount from whatever baseline count already +// exists, then confirms one more Create is rejected. It only ever creates +// (and cleans up) providers relative to the observed baseline, so it +// tolerates a non-empty account, but — like any test of a truly +// account-global, unscoped quota — it assumes no other test is +// concurrently creating/deleting OIDC providers, which holds for this +// suite's default sequential execution (not necessarily under --parallel). +func IAMCreateOpenIDConnectProvider_quota_exceeded(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_quota_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + baseline, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + + var created []string + defer func() { + for _, arn := range created { + if deleteErr := deleteOIDCProvider(client, arn); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM OIDC provider %q: %w", arn, deleteErr)) + } + } + }() + + for i := len(baseline.OpenIDConnectProviderList); i < storage.MaxOIDCProvidersPerAccount; i++ { + arn, createErr := createTestOIDCProvider(client) + if createErr != nil { + return fmt.Errorf("topping up to quota: %w", createErr) + } + created = append(created, arn) + } + + _, overErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(overErr, iamerr.OIDCProvidersPerAccountLimitExceeded(storage.MaxOIDCProvidersPerAccount)) + }) +} + +func IAMCreateOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com"}, + ThumbprintList: []string{strings.ToUpper(validOIDCThumbprint)}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := func() error { + wantArn := oidcProviderArn(providerURL) + if aws.ToString(out.OpenIDConnectProviderArn) != wantArn { + return fmt.Errorf("expected OpenIDConnectProviderArn %q, instead got %q", wantArn, aws.ToString(out.OpenIDConnectProviderArn)) + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected create output tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected CreateOpenIDConnectProvider response request id") + } + + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(get.Url) != wantURL { + return fmt.Errorf("expected Url %q (scheme stripped), instead got %q", wantURL, aws.ToString(get.Url)) + } + if len(get.ClientIDList) != 1 || get.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", get.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(get.ThumbprintList) != 1 || get.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s] (lowercased), instead got %#v", validOIDCThumbprint, get.ThumbprintList) + } + if get.CreateDate == nil || get.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateOpenIDConnectProvider_defaults(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + + checkErr := func() error { + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags in create output, instead got %#v", out.Tags) + } + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + if len(get.ClientIDList) != 0 { + return fmt.Errorf("expected no client ids, instead got %#v", get.ClientIDList) + } + if len(get.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %#v", get.Tags) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_ip_literal_host confirms an IP-literal +// host is accepted by exercising isValidOIDCHostname's net.ParseIP branch +// end-to-end. +func IAMCreateOpenIDConnectProvider_ip_literal_host(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_ip_literal_host" + return iamActionHandler(s, testName, func(client *iam.Client) error { + host := newIAMOIDCProviderIPHost() + arn, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + + get, getErr := getIAMOIDCProvider(client, arn) + checkErr := getErr + if getErr == nil && aws.ToString(get.Url) != host { + checkErr = fmt.Errorf("expected Url %q, instead got %q", host, aws.ToString(get.Url)) + } + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_edge_cases exercises two +// success-path ThumbprintList edge cases in one pass: exactly +// MaxThumbprintsPerOIDCProvider entries (the limit message says "fewer +// than 5", but 5 itself is accepted), and a 40-character entry outside the +// hex charset (AWS does not check for a hex charset). +func IAMCreateOpenIDConnectProvider_thumbprint_edge_cases(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases" + return iamActionHandler(s, testName, func(client *iam.Client) error { + checkThumbprints := func(thumbprints []string) error { + arn, err := createOIDCProviderReturningArn(client, thumbprints) + if err != nil { + return err + } + return deleteOIDCProvider(client, arn) + } + + if err := checkThumbprints([]string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + }); err != nil { + return fmt.Errorf("max_thumbprints_boundary: %w", err) + } + + if err := checkThumbprints([]string{strings.Repeat("z", 40)}); err != nil { + return fmt.Errorf("non_hex_thumbprint: %w", err) + } + return nil + }) +} + +// IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity confirms +// that a trailing slash is part of a provider's identity: "https://host" +// and "https://host/" register as two distinct providers, not a +// collision. +func IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + host := "oidc-test-" + genRandString(16) + ".example.com" + withoutSlash, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withoutSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + withSlash, err := createTestOIDCProviderWithURL(client, "https://"+host+"/") + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + if withoutSlash == withSlash { + return fmt.Errorf("expected distinct ARNs for %q and %q, both got %q", host, host+"/", withoutSlash) + } + return nil + }) +} + +// newIAMOIDCProviderURL returns a fresh https:// URL for a throwaway OIDC +// provider. Provider identity is the URL itself (there is no separate +// name), so genRandString's collision-free counter is what keeps +// concurrent/repeated test runs from colliding with each other or with any +// provider left over from a prior run. +func newIAMOIDCProviderURL() string { + return "https://oidc-test-" + genRandString(16) + ".example.com" +} + +// newIAMOIDCProviderIPHost returns a host string within the TEST-NET-2 +// documentation range (RFC 5737, 198.51.100.0/24 — never publicly +// routable), used to exercise CreateOpenIDConnectProvider's IP-literal +// hostname path without depending on any real, reachable host. +func newIAMOIDCProviderIPHost() string { + suffix := genRandString(1) + return fmt.Sprintf("198.51.100.%d", int(suffix[0])%254+1) +} + +func createOIDCProvider(client *iam.Client, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateOpenIDConnectProvider(ctx, input) +} + +// createTestOIDCProvider creates a provider at a fresh random URL with a +// single explicit valid thumbprint (bypassing the network-dependent +// auto-fetch path) and returns its ARN. +func createTestOIDCProvider(client *iam.Client) (string, error) { + return createTestOIDCProviderWithURL(client, newIAMOIDCProviderURL()) +} + +func createTestOIDCProviderWithURL(client *iam.Client, providerURL string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} + +func deleteOIDCProvider(client *iam.Client, arn string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteOpenIDConnectProvider(ctx, &iam.DeleteOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) + return err +} + +// oidcProviderArn builds the expected ARN for a provider created at +// providerURL, mirroring iamutil.BuildOIDCProviderArn without importing an +// internal package from this external test tree. +func oidcProviderArn(providerURL string) string { + return "arn:aws:iam::000000000000:oidc-provider/" + strings.TrimPrefix(providerURL, "https://") +} + +func createOIDCProviderReturningArn(client *iam.Client, thumbprints []string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: thumbprints, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} diff --git a/tests/integration/iam_delete_oidc_provider.go b/tests/integration/iam_delete_oidc_provider.go new file mode 100644 index 00000000..47526ece --- /dev/null +++ b/tests/integration/iam_delete_oidc_provider.go @@ -0,0 +1,84 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_missing_arn" + body := []byte("Action=DeleteOpenIDConnectProvider&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMDeleteOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +func IAMDeleteOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + _, err = getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +// IAMDeleteOpenIDConnectProvider_not_idempotent confirms a second delete +// of the same ARN fails. +func IAMDeleteOpenIDConnectProvider_not_idempotent(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_not_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + err = deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} diff --git a/tests/integration/iam_get_oidc_provider.go b/tests/integration/iam_get_oidc_provider.go new file mode 100644 index 00000000..22469f2e --- /dev/null +++ b/tests/integration/iam_get_oidc_provider.go @@ -0,0 +1,143 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_missing_arn" + body := []byte("Action=GetOpenIDConnectProvider&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMGetOpenIDConnectProvider_invalid_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := getIAMOIDCProvider(client, tt.arn) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMGetOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMGetOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + created, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + arn := aws.ToString(created.OpenIDConnectProviderArn) + + checkErr := func() error { + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(out.Url) != wantURL { + return fmt.Errorf("expected Url %q, instead got %q", wantURL, aws.ToString(out.Url)) + } + wantClientIDs := []string{"sts.amazonaws.com", "another-client"} + if len(out.ClientIDList) != len(wantClientIDs) { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + for i, id := range wantClientIDs { + if out.ClientIDList[i] != id { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + } + if len(out.ThumbprintList) != 1 || out.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s], instead got %#v", validOIDCThumbprint, out.ThumbprintList) + } + if out.CreateDate == nil || out.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetOpenIDConnectProvider response request id") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMOIDCProvider(client *iam.Client, arn string) (*iam.GetOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetOpenIDConnectProvider(ctx, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) +} diff --git a/tests/integration/iam_list_oidc_providers.go b/tests/integration/iam_list_oidc_providers.go new file mode 100644 index 00000000..b5d2b5b3 --- /dev/null +++ b/tests/integration/iam_list_oidc_providers.go @@ -0,0 +1,122 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +func IAMListOpenIDConnectProviders_success(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviders_success" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + before, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(before.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListOpenIDConnectProviders response request id") + } + baseline := oidcProviderArnSet(before) + + arnA, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + arnB, err := createTestOIDCProvider(client) + if err != nil { + delErr := deleteOIDCProvider(client, arnA) + return errors.Join(err, delErr) + } + + cleanup := func(arns ...string) error { + var errs error + for _, arn := range arns { + if delErr := deleteOIDCProvider(client, arn); delErr != nil { + errs = errors.Join(errs, delErr) + } + } + return errs + } + + afterCreate, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnA, arnB)) + } + createdSet := oidcProviderArnSet(afterCreate) + if _, ok := createdSet[arnA]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnA), cleanup(arnA, arnB)) + } + if _, ok := createdSet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnB), cleanup(arnA, arnB)) + } + for arn := range baseline { + if _, ok := createdSet[arn]; !ok { + return errors.Join(fmt.Errorf("expected pre-existing %q to still be listed", arn), cleanup(arnA, arnB)) + } + } + + if err := deleteOIDCProvider(client, arnA); err != nil { + return errors.Join(err, cleanup(arnB)) + } + + afterDeleteA, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnB)) + } + afterDeleteASet := oidcProviderArnSet(afterDeleteA) + if _, ok := afterDeleteASet[arnA]; ok { + return errors.Join(fmt.Errorf("expected %q to be absent after delete", arnA), cleanup(arnB)) + } + if _, ok := afterDeleteASet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q still listed", arnB), cleanup(arnB)) + } + + if err := deleteOIDCProvider(client, arnB); err != nil { + return err + } + + afterDeleteB, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if _, ok := oidcProviderArnSet(afterDeleteB)[arnB]; ok { + return fmt.Errorf("expected %q to be absent after delete", arnB) + } + + return nil + }) +} + +func listIAMOIDCProviders(client *iam.Client) (*iam.ListOpenIDConnectProvidersOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListOpenIDConnectProviders(ctx, &iam.ListOpenIDConnectProvidersInput{}) +} + +func oidcProviderArnSet(out *iam.ListOpenIDConnectProvidersOutput) map[string]struct{} { + set := make(map[string]struct{}, len(out.OpenIDConnectProviderList)) + for _, p := range out.OpenIDConnectProviderList { + if p.Arn != nil { + set[*p.Arn] = struct{}{} + } + } + return set +} diff --git a/tests/integration/iam_remove_client_id_from_oidc_provider.go b/tests/integration/iam_remove_client_id_from_oidc_provider.go new file mode 100644 index 00000000..fa214b1c --- /dev/null +++ b/tests/integration/iam_remove_client_id_from_oidc_provider.go @@ -0,0 +1,163 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(removeClientIDFromOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := func() error { + if err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + got, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(got.ClientIDList) != 1 || got.ClientIDList[0] != "another-client" { + return fmt.Errorf("expected ClientIDList [another-client], instead got %#v", got.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent confirms +// removing a client ID that was never added succeeds silently rather than +// erroring. +func IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := removeClientIDFromOIDCProvider(client, arn, "never-added") + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func removeClientIDFromOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.RemoveClientIDFromOpenIDConnectProvider(ctx, &iam.RemoveClientIDFromOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_update_oidc_provider_thumbprint.go b/tests/integration/iam_update_oidc_provider_thumbprint.go new file mode 100644 index 00000000..143e54ae --- /dev/null +++ b/tests/integration/iam_update_oidc_provider_thumbprint.go @@ -0,0 +1,185 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_arn(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn" + body := []byte(url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "Version": {"2010-05-08"}, + "ThumbprintList.member.1": {validOIDCThumbprint}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, []string{}), iamerr.ThumbprintListEmpty()) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40), + } + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, thumbprints), iamerr.ThumbprintListTooLong(5)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr( + updateOIDCProviderThumbprint(client, arn, []string{strings.Repeat("a", 39)}), + iamerr.InvalidInput("Thumbprint must be exactly 40 characters."), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := updateOIDCProviderThumbprint(client, arn, []string{validOIDCThumbprint}) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_success(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + newThumbprints := []string{strings.Repeat("A", 40), strings.Repeat("B", 40)} + if err := updateOIDCProviderThumbprint(client, arn, newThumbprints); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + // Full replace (the original validOIDCThumbprint must be gone), + // lowercased (submitted uppercase). + want := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(out.ThumbprintList, want) { + return fmt.Errorf("expected ThumbprintList %#v, instead got %#v", want, out.ThumbprintList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints +// confirms exactly MaxThumbprintsPerOIDCProvider entries succeeds — the +// limit message says "fewer than 5", but 5 itself is accepted. +func IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + } + checkErr := updateOIDCProviderThumbprint(client, arn, thumbprints) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateOIDCProviderThumbprint(client *iam.Client, arn string, thumbprints []string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.UpdateOpenIDConnectProviderThumbprint(ctx, &iam.UpdateOpenIDConnectProviderThumbprintInput{ + OpenIDConnectProviderArn: &arn, + ThumbprintList: thumbprints, + }) + return err +} From 4b99caaf141e5475fab502e5c7c1620ea3a8f3f2 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 5 Aug 2026 16:11:36 +0400 Subject: [PATCH 6/7] feat: add STS web identity federation, IAM policy Condition support, and access control enforcement Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size. Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions. Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control. Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth. Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities. --- chart/templates/deployment.yaml | 4 +- cmd/versitygw/gateway_test.go | 4 +- cmd/versitygw/iam.go | 7 +- cmd/versitygw/main.go | 34 +- cmd/versitygw/test.go | 13 +- debuglogger/level.go | 90 + debuglogger/level_test.go | 97 + debuglogger/logger.go | 66 +- debuglogger/redact.go | 135 + debuglogger/redact_test.go | 191 ++ debuglogger/xmlmask.go | 221 ++ debuglogger/xmlmask_test.go | 138 + embedgw/embedgw.go | 17 +- embedgw/iam.go | 11 +- extra/example.conf | 25 +- go.mod | 8 +- go.sum | 4 +- iamapi/authentication_test.go | 121 +- iamapi/authorization_test.go | 602 ++++ iamapi/controller.go | 283 +- iamapi/controller_test.go | 1036 +++++- iamapi/iamerr/errors.go | 85 + iamapi/internal/iammiddleware/auth.go | 269 +- iamapi/internal/iammiddleware/policy.go | 403 +++ iamapi/internal/iamutil/access_key.go | 40 + iamapi/internal/iamutil/oidc_thumbprint.go | 66 +- .../internal/iamutil/oidc_thumbprint_test.go | 54 +- iamapi/internal/iamutil/request_test.go | 51 + iamapi/internal/iamutil/user.go | 21 + iamapi/internal/iamutil/webidentity.go | 887 +++++ iamapi/internal/iamutil/webidentity_test.go | 587 ++++ iamapi/policy/condition.go | 500 +++ iamapi/policy/condition_test.go | 761 +++++ iamapi/policy/document.go | 101 +- iamapi/policy/document_test.go | 43 + iamapi/policy/identity.go | 150 + iamapi/policy/identity_test.go | 268 ++ iamapi/policy/trust.go | 219 +- iamapi/policy/trust_test.go | 68 +- iamapi/policy/validate.go | 10 +- iamapi/policy/validate_test.go | 14 +- iamapi/policy/webidentity.go | 303 ++ iamapi/policy/webidentity_test.go | 296 ++ iamapi/response.go | 16 + iamapi/router.go | 54 +- iamapi/server.go | 3 + iamapi/storage/internal.go | 135 + iamapi/storage/storer.go | 27 + iamapi/storage/storer_test.go | 191 +- iamapi/storage/vault.go | 1240 ++++--- iamapi/types/identity.go | 48 + iamapi/types/sts.go | 98 + internal/httpctx/context_keys.go | 1 + internal/sigv4auth/auth.go | 5 + internal/sigv4auth/compare.go | 33 + internal/sigv4auth/compare_test.go | 39 + internal/sigv4auth/query.go | 8 +- internal/sigv4auth/verify.go | 9 +- s3api/admin-server.go | 3 + s3api/server.go | 3 + tests/integration/group-tests.go | 2400 +++++++------- tests/integration/iam_access_control.go | 2843 +++++++++++++++++ .../iam_assume_role_with_web_identity.go | 687 ++++ tests/integration/iam_get_caller_identity.go | 176 + tests/integration/s3conf.go | 6 + 65 files changed, 14677 insertions(+), 1651 deletions(-) create mode 100644 debuglogger/level.go create mode 100644 debuglogger/level_test.go create mode 100644 debuglogger/redact.go create mode 100644 debuglogger/redact_test.go create mode 100644 debuglogger/xmlmask.go create mode 100644 debuglogger/xmlmask_test.go create mode 100644 iamapi/authorization_test.go create mode 100644 iamapi/internal/iammiddleware/policy.go create mode 100644 iamapi/internal/iamutil/webidentity.go create mode 100644 iamapi/internal/iamutil/webidentity_test.go create mode 100644 iamapi/policy/condition.go create mode 100644 iamapi/policy/condition_test.go create mode 100644 iamapi/policy/identity.go create mode 100644 iamapi/policy/identity_test.go create mode 100644 iamapi/policy/webidentity.go create mode 100644 iamapi/policy/webidentity_test.go create mode 100644 iamapi/types/identity.go create mode 100644 iamapi/types/sts.go create mode 100644 internal/sigv4auth/compare.go create mode 100644 internal/sigv4auth/compare_test.go create mode 100644 tests/integration/iam_access_control.go create mode 100644 tests/integration/iam_assume_role_with_web_identity.go create mode 100644 tests/integration/iam_get_caller_identity.go diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 1f57429b..c487b23a 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -92,8 +92,8 @@ spec: value: "true" {{- end }} {{- if .Values.gateway.debug }} - - name: VGW_DEBUG - value: "true" + - name: VGW_LOG_LEVEL + value: "debug" {{- end }} {{- if .Values.gateway.accessLog }} - name: VGW_ACCESS_LOG diff --git a/cmd/versitygw/gateway_test.go b/cmd/versitygw/gateway_test.go index 929b7733..62a9307c 100644 --- a/cmd/versitygw/gateway_test.go +++ b/cmd/versitygw/gateway_test.go @@ -24,7 +24,7 @@ var ( func initEnv(dir string) { // both - debug = true + logLevel = "debug" region = "us-east-1" // server @@ -97,7 +97,7 @@ func TestIntegration(t *testing.T) { integration.WithRegion(region), integration.WithEndpoint(endpoint), } - if debug { + if logLevel != "silent" && logLevel != "" { opts = append(opts, integration.WithDebug()) } diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index 110446bd..204a4d10 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -159,6 +159,11 @@ 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, @@ -167,7 +172,7 @@ func runIAM(ctx *cli.Context) error { MaxRequests: maxRequests, CertFile: certFile, KeyFile: keyFile, - Debug: debug, + LogLevel: logLvl, Quiet: quiet, KeepAlive: keepAlive, HealthPath: healthPath, diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index f366fa69..c950873f 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -24,6 +24,7 @@ import ( "github.com/urfave/cli/v2" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/embedgw" "github.com/versity/versitygw/s3api/utils" ) @@ -49,6 +50,7 @@ var ( adminLogFile string healthPath string virtualDomain string + logLevel string debug bool keepAlive bool pprof string @@ -372,9 +374,19 @@ func initFlags() []cli.Flag { EnvVars: []string{"VGW_ADMIN_CERT_KEY"}, Destination: &admKeyFile, }, + &cli.StringFlag{ + Name: "log-level", + Usage: `debug logger verbosity: "silent" (default, no debug output), ` + + `"debug" (full request/response logging with secrets and tokens masked), or ` + + `"unsafe" (full logging with NO masking -- prints access keys, secrets, session ` + + `tokens, and signatures in the clear; only use for local troubleshooting, never in production)`, + Value: "silent", + EnvVars: []string{"VGW_LOG_LEVEL"}, + Destination: &logLevel, + }, &cli.BoolFlag{ Name: "debug", - Usage: "enable debug output", + Usage: "enable debug output (deprecated: use --log-level=debug for finer-grained control)", Value: false, EnvVars: []string{"VGW_DEBUG"}, Destination: &debug, @@ -813,6 +825,19 @@ func initFlags() []cli.Flag { } } +// parseLogLevel parses the --log-level flag value shared by the gateway and +// standalone IAM API commands. --debug is a deprecated alias for +// --log-level=debug, kept for backward compatibility. +func parseLogLevel() (debuglogger.Level, error) { + if debug { + fmt.Fprintf(os.Stderr, "WARNING: --debug is deprecated; use --log-level=debug for finer-grained control over debug logging\n") + if logLevel == "silent" { + return debuglogger.LevelDebug, nil + } + } + return debuglogger.ParseLevel(logLevel) +} + func runGateway(ctx context.Context, be backend.Backend) error { if pprof != "" { // Listen on the specified address for pprof debug endpoints. @@ -829,6 +854,11 @@ func runGateway(ctx context.Context, be backend.Backend) error { return fmt.Errorf("copy-object-threshold must be positive") } + logLvl, err := parseLogLevel() + if err != nil { + return err + } + return embedgw.RunVersityGW(ctx, be, &embedgw.Config{ RootUserAccess: rootUserAccess, RootUserSecret: rootUserSecret, @@ -845,7 +875,7 @@ func runGateway(ctx context.Context, be backend.Backend) error { AdminCertFile: admCertFile, AdminKeyFile: admKeyFile, CORSAllowOrigin: corsAllowOrigin, - Debug: debug, + LogLevel: logLvl, IAMDebug: iamDebug, Quiet: quiet, Readonly: readonly, diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 8cc7826e..44d38ec3 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -42,6 +42,7 @@ var ( checksumDisable bool versioningEnabled bool azureTests bool + testDebug bool tlsStatus bool parallel bool windowsTests bool @@ -91,7 +92,7 @@ func initTestFlags() []cli.Flag { Name: "debug", Usage: "enable debug mode", Aliases: []string{"d"}, - Destination: &debug, + Destination: &testDebug, }, &cli.BoolFlag{ Name: "allow-insecure", @@ -296,7 +297,7 @@ func initTestCommands() []*cli.Command { integration.WithPartSize(partSize), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if hostStyle { @@ -357,7 +358,7 @@ func initTestCommands() []*cli.Command { integration.WithConcurrency(concurrency), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if checksumDisable { @@ -404,7 +405,7 @@ func websiteHostingAction(ctx *cli.Context) error { if websitePortTest != "" { opts = append(opts, integration.WithWebsitePort(websitePortTest)) } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } @@ -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 { @@ -480,7 +481,7 @@ func extractIntTests() (commands []*cli.Command) { integration.WithEndpoint(endpoint), integration.WithTLSStatus(tlsStatus), } - if debug { + if testDebug { opts = append(opts, integration.WithDebug()) } if versioningEnabled { diff --git a/debuglogger/level.go b/debuglogger/level.go new file mode 100644 index 00000000..37b47f00 --- /dev/null +++ b/debuglogger/level.go @@ -0,0 +1,90 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import ( + "fmt" + "strings" + "sync/atomic" +) + +// Level controls both whether the debug logger produces any output and, +// when it does, whether secrets and tokens embedded in that output are +// masked. +type Level int32 + +const ( + // LevelSilent prints no debug logs. This is the default. + LevelSilent Level = iota + // LevelDebug prints full request/response logs with secrets and + // tokens (access keys, session tokens, signatures, ...) masked. + LevelDebug + // LevelUnsafe prints full request/response logs with secrets and + // tokens shown in the clear. Anyone with access to this output can + // read and replay credentials directly; never use in production. + LevelUnsafe +) + +func (l Level) String() string { + switch l { + case LevelSilent: + return "silent" + case LevelDebug: + return "debug" + case LevelUnsafe: + return "unsafe" + default: + return "unknown" + } +} + +// ParseLevel parses "silent", "debug", or "unsafe" (case-insensitive) into +// a Level. An empty string parses as LevelSilent. +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "silent": + return LevelSilent, nil + case "debug": + return LevelDebug, nil + case "unsafe": + return LevelUnsafe, nil + default: + return LevelSilent, fmt.Errorf("invalid log level %q: must be one of 'silent', 'debug', 'unsafe'", s) + } +} + +var currentLevel atomic.Int32 + +// SetLevel sets the active debug log level. +func SetLevel(l Level) { + currentLevel.Store(int32(l)) +} + +// CurrentLevel returns the active debug log level. +func CurrentLevel() Level { + return Level(currentLevel.Load()) +} + +// IsDebugEnabled returns true when the debug logger produces output, at +// either LevelDebug or LevelUnsafe. +func IsDebugEnabled() bool { + return CurrentLevel() != LevelSilent +} + +// IsUnsafeEnabled returns true when the debug logger is configured to print +// secrets and tokens without masking. +func IsUnsafeEnabled() bool { + return CurrentLevel() == LevelUnsafe +} diff --git a/debuglogger/level_test.go b/debuglogger/level_test.go new file mode 100644 index 00000000..c21e601c --- /dev/null +++ b/debuglogger/level_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import "testing" + +func TestParseLevel(t *testing.T) { + tests := []struct { + in string + want Level + wantErr bool + }{ + {"silent", LevelSilent, false}, + {"", LevelSilent, false}, + {"SILENT", LevelSilent, false}, + {"debug", LevelDebug, false}, + {" Debug ", LevelDebug, false}, + {"unsafe", LevelUnsafe, false}, + {"UNSAFE", LevelUnsafe, false}, + {"verbose", LevelSilent, true}, + {"true", LevelSilent, true}, + } + for _, tt := range tests { + got, err := ParseLevel(tt.in) + if (err != nil) != tt.wantErr { + t.Errorf("ParseLevel(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr) + continue + } + if err == nil && got != tt.want { + t.Errorf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestLevelGatesDebugAndUnsafe(t *testing.T) { + defer SetLevel(LevelSilent) + + SetLevel(LevelSilent) + if IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelSilent = true, want false") + } + if IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelSilent = true, want false") + } + + SetLevel(LevelDebug) + if !IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelDebug = false, want true") + } + if IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelDebug = true, want false") + } + + SetLevel(LevelUnsafe) + if !IsDebugEnabled() { + t.Error("IsDebugEnabled() at LevelUnsafe = false, want true") + } + if !IsUnsafeEnabled() { + t.Error("IsUnsafeEnabled() at LevelUnsafe = false, want true") + } +} + +func TestIsIAMDebugEnabledRequiresBothLevelAndIAMFlag(t *testing.T) { + defer func() { + SetLevel(LevelSilent) + debugIAMEnabled.Store(false) + }() + + SetLevel(LevelSilent) + debugIAMEnabled.Store(true) + if IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with iam-debug set but level silent = true, want false") + } + + SetLevel(LevelDebug) + debugIAMEnabled.Store(false) + if IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with level debug but iam-debug unset = true, want false") + } + + debugIAMEnabled.Store(true) + if !IsIAMDebugEnabled() { + t.Error("IsIAMDebugEnabled() with level debug and iam-debug set = false, want true") + } +} diff --git a/debuglogger/logger.go b/debuglogger/logger.go index 8e06d2b8..2ef2ed37 100644 --- a/debuglogger/logger.go +++ b/debuglogger/logger.go @@ -64,30 +64,46 @@ func printError(prefix prefix, er error) { // Logs http request details: headers, body, params, query args func LogFiberRequestDetails(ctx fiber.Ctx) { - // Log the full request url - fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.OriginalURL() + // Log the full request url, with sensitive query parameter values + // redacted (ctx.OriginalURL() would print them in the clear). + fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.Path() + if qs := debugRedactedQueryString(ctx.Request().URI().QueryArgs()); qs != "" { + fullURL += "?" + qs + } fmt.Printf("%s[URL]: %s%s\n", green, fullURL, reset) // log request headers wrapInBox(green, "REQUEST HEADERS", boxWidth, func() { for key, value := range ctx.Request().Header.All() { - printWrappedLine(yellow, string(key), string(value)) + printWrappedLine(yellow, string(key), debugRedact(string(key), string(value))) } }) // skip request body log for PutObject and UploadPart skipBodyLog := isLargeDataAction(ctx) if !skipBodyLog { - body := ctx.Request().Body() - if len(body) != 0 { + if postArgs := ctx.Request().PostArgs(); postArgs.Len() != 0 { + // form-encoded body (e.g. AWS Query protocol requests like + // IAM/STS): log key=value pairs so sensitive fields (e.g. + // WebIdentityToken) can be redacted individually, instead of + // printing the raw, still-encoded body bytes. printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false) - fmt.Printf("%s%s%s\n", blue, body, reset) + for key, value := range postArgs.All() { + fmt.Printf("%s%s=%s%s\n", blue, key, debugRedact(string(key), string(value)), reset) + } printHorizontalBorder(blue, boxWidth, false) + } else { + body := ctx.Request().Body() + if len(body) != 0 { + printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false) + fmt.Printf("%s%s%s\n", blue, formatBodyForLog(body), reset) + printHorizontalBorder(blue, boxWidth, false) + } } } if ctx.Request().URI().QueryArgs().Len() != 0 { for key, value := range ctx.Request().URI().QueryArgs().All() { - log.Printf("%s: %s", key, value) + log.Printf("%s: %s", key, debugRedact(string(key), string(value))) } } } @@ -96,7 +112,7 @@ func LogFiberRequestDetails(ctx fiber.Ctx) { func LogFiberResponseDetails(ctx fiber.Ctx) { wrapInBox(green, "RESPONSE HEADERS", boxWidth, func() { for key, value := range ctx.Response().Header.All() { - printWrappedLine(yellow, string(key), string(value)) + printWrappedLine(yellow, string(key), debugRedact(string(key), string(value))) } }) @@ -104,27 +120,26 @@ func LogFiberResponseDetails(ctx fiber.Ctx) { if !ok { body := ctx.Response().Body() if len(body) != 0 { - PrintInsideHorizontalBorders(blue, "RESPONSE BODY", string(body), boxWidth) + PrintInsideHorizontalBorders(blue, "RESPONSE BODY", formatBodyForLog(body), boxWidth) } } } -var debugEnabled atomic.Bool - -// SetDebugEnabled sets the debug mode -func SetDebugEnabled() { - debugEnabled.Store(true) -} - -// IsDebugEnabled returns true if debugging is enabled -func IsDebugEnabled() bool { - return debugEnabled.Load() +// formatBodyForLog returns body pretty-printed with property-level secret +// masking when it parses as XML (the case for every S3 and IAM API request +// or response body reaching this point), and the raw body unchanged +// otherwise. Masking is skipped entirely at LevelUnsafe. +func formatBodyForLog(body []byte) string { + if masked, ok := maskXMLBody(body); ok { + return string(masked) + } + return string(body) } // Logf is the same as 'fmt.Printf' with debug prefix, // a color added and '\n' at the end func Logf(format string, v ...any) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } @@ -133,7 +148,7 @@ func Logf(format string, v ...any) { // Infof prints out green info block with [INFO]: prefix func Infof(format string, v ...any) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } @@ -147,15 +162,16 @@ func SetIAMDebugEnabled() { debugIAMEnabled.Store(true) } -// IsDebugEnabled returns true if debugging enabled +// IsIAMDebugEnabled returns true if IAM subsystem debugging is enabled: the +// --iam-debug flag was set and the log level is not silent. func IsIAMDebugEnabled() bool { - return debugEnabled.Load() + return IsDebugEnabled() && debugIAMEnabled.Load() } // IAMLogf is the same as 'fmt.Printf' with debug prefix, // a color added and '\n' at the end func IAMLogf(format string, v ...any) { - if !debugIAMEnabled.Load() { + if !IsIAMDebugEnabled() { return } @@ -165,7 +181,7 @@ func IAMLogf(format string, v ...any) { // PrintInsideHorizontalBorders prints the text inside horizontal // border and title in the center of upper border func PrintInsideHorizontalBorders(color Color, title, text string, width int) { - if !debugEnabled.Load() { + if !IsDebugEnabled() { return } printBoxTitleLine(color, title, width, false) diff --git a/debuglogger/redact.go b/debuglogger/redact.go new file mode 100644 index 00000000..a7fe4346 --- /dev/null +++ b/debuglogger/redact.go @@ -0,0 +1,135 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import ( + "net/url" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/valyala/fasthttp" +) + +// redactedValue replaces the value of a matched sensitive field entirely. +// The debug logger uses the same mask character for the partial masking +// applied to fields like AccessKeyId +const redactedValue = "****" + +// sensitiveFieldNames lists header, query, and form field names (matched +// case-insensitively) whose values are bearer credentials or raw key +// material rather than diagnostic data: a JWT, a session token, a request +// signature, or an SSE-C encryption key. Anyone with log access could +// replay or reuse a logged value directly, so these are replaced with +// redactedValue everywhere a request or response is logged, in both normal +// and debug-mode logging. +var sensitiveFieldNames = map[string]bool{ + "authorization": true, + "x-amz-security-token": true, + "webidentitytoken": true, + // The request signature itself: with the rest of a presigned URL + // (which is not otherwise secret) this is everything needed to replay + // the exact request until it expires. + "x-amz-signature": true, + // Carries the access key ID. Not secret on its own, but there's no + // diagnostic value in logging it that isn't already available from + // the (also masked) Authorization header, so mask it defensively too. + "x-amz-credential": true, + // SSE-C requests carry the raw AES-256 customer-provided encryption + // key in these headers. The paired "...-key-md5" headers are just a + // checksum of the key (not reversible to the key itself), so they're + // left unmasked to help correlate requests using the same key. + "x-amz-server-side-encryption-customer-key": true, + "x-amz-copy-source-server-side-encryption-customer-key": true, +} + +func isSensitiveFieldName(name string) bool { + return sensitiveFieldNames[strings.ToLower(name)] +} + +// redact returns redactedValue in place of value when key names a +// credential-bearing header, query, or form field. +func redact(key, value string) string { + if isSensitiveFieldName(key) { + return redactedValue + } + return value +} + +// RedactedQueryString rebuilds the request's query string with sensitive +// parameter values (see sensitiveFieldNames) replaced by redactedValue. It +// is safe to write to any log, including the default (non-debug) access +// log. +func RedactedQueryString(queryArgs *fasthttp.Args) string { + if queryArgs.Len() == 0 { + return "" + } + + var b strings.Builder + first := true + for key, value := range queryArgs.All() { + if !first { + b.WriteByte('&') + } + first = false + b.WriteString(url.QueryEscape(string(key))) + b.WriteByte('=') + b.WriteString(url.QueryEscape(redact(string(key), string(value)))) + } + return b.String() +} + +// RedactedQueryParamsTag is a logger.LogFunc that replaces the fiber logger +// middleware's built-in ${queryParams} tag with a redacted query string +// (see RedactedQueryString). Register it as a CustomTags override for +// logger.TagQueryStringParams so the default (non-debug) access log never +// writes credential-bearing query parameters such as WebIdentityToken or +// X-Amz-Security-Token. +var RedactedQueryParamsTag logger.LogFunc = func(output logger.Buffer, ctx fiber.Ctx, _ *logger.Data, _ string) (int, error) { + return output.WriteString(RedactedQueryString(ctx.Request().URI().QueryArgs())) +} + +// debugRedact is redact's counterpart for the debug logger's own +// header/query/form-field printing. Unlike redact (used by the always-on, +// non-debug access log), it honors LevelUnsafe: at that level it returns +// value unchanged so the debug output shows exactly what was on the wire. +// At LevelDebug it masks identically to redact. +func debugRedact(key, value string) string { + if IsUnsafeEnabled() { + return value + } + return redact(key, value) +} + +// debugRedactedQueryString is RedactedQueryString's counterpart for the +// debug logger, using debugRedact so LevelUnsafe shows unmasked values. +func debugRedactedQueryString(queryArgs *fasthttp.Args) string { + if queryArgs.Len() == 0 { + return "" + } + + var b strings.Builder + first := true + for key, value := range queryArgs.All() { + if !first { + b.WriteByte('&') + } + first = false + b.WriteString(url.QueryEscape(string(key))) + b.WriteByte('=') + b.WriteString(url.QueryEscape(debugRedact(string(key), string(value)))) + } + return b.String() +} diff --git a/debuglogger/redact_test.go b/debuglogger/redact_test.go new file mode 100644 index 00000000..1abe6af3 --- /dev/null +++ b/debuglogger/redact_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" +) + +func TestRedact(t *testing.T) { + tests := []struct { + name string + key string + value string + want string + }{ + {name: "Authorization header", key: "Authorization", value: "AWS4-HMAC-SHA256 ...", want: redactedValue}, + {name: "header name matched case-insensitively", key: "AUTHORIZATION", value: "secret", want: redactedValue}, + {name: "security token", key: "X-Amz-Security-Token", value: "secret", want: redactedValue}, + {name: "presigned request signature", key: "X-Amz-Signature", value: "deadbeef", want: redactedValue}, + {name: "presigned request signature matched case-insensitively", key: "x-amz-signature", value: "deadbeef", want: redactedValue}, + {name: "presigned request credential", key: "X-Amz-Credential", value: "AKIAEXAMPLE/20260101/us-east-1/s3/aws4_request", want: redactedValue}, + {name: "web identity token form/query field", key: "WebIdentityToken", value: "secret", want: redactedValue}, + {name: "SSE-C customer key header", key: "X-Amz-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue}, + {name: "SSE-C copy-source customer key header", key: "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue}, + {name: "SSE-C customer key MD5 untouched (checksum, not a secret)", key: "X-Amz-Server-Side-Encryption-Customer-Key-MD5", value: "deadbeef==", want: "deadbeef=="}, + {name: "unrelated header untouched", key: "Content-Type", value: "application/xml", want: "application/xml"}, + {name: "unrelated query param untouched", key: "Action", value: "AssumeRoleWithWebIdentity", want: "AssumeRoleWithWebIdentity"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := redact(tt.key, tt.value); got != tt.want { + t.Errorf("redact(%q, %q) = %q, want %q", tt.key, tt.value, got, tt.want) + } + }) + } +} + +func TestDebugRedactHonorsUnsafeLevel(t *testing.T) { + defer SetLevel(LevelSilent) + + SetLevel(LevelDebug) + if got := debugRedact("Authorization", "secret-sig"); got != redactedValue { + t.Errorf("debugRedact at LevelDebug = %q, want %q", got, redactedValue) + } + + SetLevel(LevelUnsafe) + if got := debugRedact("Authorization", "secret-sig"); got != "secret-sig" { + t.Errorf("debugRedact at LevelUnsafe = %q, want unmasked value", got) + } +} + +func TestRedactedQueryString(t *testing.T) { + args := &fasthttp.Args{} + args.Parse("Action=AssumeRoleWithWebIdentity&WebIdentityToken=super-secret-jwt") + + got := RedactedQueryString(args) + + if strings.Contains(got, "super-secret-jwt") { + t.Fatalf("RedactedQueryString leaked the token: %q", got) + } + if !strings.Contains(got, "Action=AssumeRoleWithWebIdentity") { + t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got) + } + if !strings.Contains(got, url.QueryEscape(redactedValue)) { + t.Errorf("RedactedQueryString missing redaction marker: %q", got) + } +} + +func TestRedactedQueryStringEmpty(t *testing.T) { + if got := RedactedQueryString(&fasthttp.Args{}); got != "" { + t.Errorf("RedactedQueryString(empty) = %q, want empty string", got) + } +} + +// TestRedactedQueryStringMasksPresignedCredentials asserts that a presigned +// request's X-Amz-Signature (and X-Amz-Credential) never reach the default +// access log, since together with the rest of the (non-secret) presigned URL +// they're everything needed to replay the exact signed request until it +// expires. +func TestRedactedQueryStringMasksPresignedCredentials(t *testing.T) { + args := &fasthttp.Args{} + args.Parse("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAEXAMPLE%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=deadbeefcafe") + + got := RedactedQueryString(args) + + for _, secret := range []string{"deadbeefcafe", "AKIAEXAMPLE"} { + if strings.Contains(got, secret) { + t.Fatalf("RedactedQueryString leaked presigned credential material %q: %q", secret, got) + } + } + if !strings.Contains(got, "X-Amz-Algorithm=AWS4-HMAC-SHA256") { + t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got) + } +} + +// TestLogFiberRequestAndResponseDetailsRedactSensitiveFields sends dummy +// secrets through the request header, query, and form-body paths (plus the +// response header path) and asserts that none of them appear in the debug +// logger's captured output, only the redaction marker in their place. This +// covers a GET AssumeRoleWithWebIdentity's WebIdentityToken query parameter, +// and, in debug mode, the Authorization and X-Amz-Security-Token headers. +func TestLogFiberRequestAndResponseDetailsRedactSensitiveFields(t *testing.T) { + const ( + dummyToken = "dummy-web-identity-jwt" + dummyAuth = "AWS4-HMAC-SHA256 Credential=AKIADUMMYEXAMPLE/..." + dummySecurity = "dummy-security-token" + ) + + app := fiber.New() + app.Post("/", func(ctx fiber.Ctx) error { + LogFiberRequestDetails(ctx) + ctx.Response().Header.Set("X-Amz-Security-Token", dummySecurity) + LogFiberResponseDetails(ctx) + return ctx.SendString("ok") + }) + + body := "Action=AssumeRoleWithWebIdentity&WebIdentityToken=" + dummyToken + req := httptest.NewRequest(http.MethodPost, "/?WebIdentityToken="+dummyToken, strings.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + req.Header.Set("Authorization", dummyAuth) + req.Header.Set("X-Amz-Security-Token", dummySecurity) + + output := captureLogOutput(t, func() { + if _, err := app.Test(req); err != nil { + t.Fatalf("app.Test: %v", err) + } + }) + + for _, secret := range []string{dummyToken, dummyAuth, dummySecurity} { + if strings.Contains(output, secret) { + t.Errorf("captured debug output leaked secret %q:\n%s", secret, output) + } + } + if !strings.Contains(output, redactedValue) { + t.Errorf("expected redaction marker %q in captured output:\n%s", redactedValue, output) + } +} + +// captureLogOutput redirects both fmt.Printf (via os.Stdout, used by the +// box-drawing helpers) and the standard "log" package (used for the +// per-query-arg lines) into a buffer for the duration of fn. +func captureLogOutput(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + origStdout := os.Stdout + origLogOutput := log.Writer() + os.Stdout = w + log.SetOutput(w) + defer func() { + os.Stdout = origStdout + log.SetOutput(origLogOutput) + }() + + fn() + + w.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy: %v", err) + } + return buf.String() +} diff --git a/debuglogger/xmlmask.go b/debuglogger/xmlmask.go new file mode 100644 index 00000000..52b254e3 --- /dev/null +++ b/debuglogger/xmlmask.go @@ -0,0 +1,221 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import ( + "bytes" + "encoding/xml" + "fmt" + "strings" +) + +// accessKeyVisiblePrefixLen is the number of leading characters left +// visible when partially masking an access key ID (e.g. "AKIA" or "ASIA"), +// enough to identify the credential type without exposing the value. +const accessKeyVisiblePrefixLen = 4 + +// fullyMaskedXMLElements lists XML element (and attribute) local names +// whose text content is a usable credential. Every occurrence, at any +// nesting depth, is replaced with redactedValue when masking applies. +var fullyMaskedXMLElements = map[string]bool{ + "SecretAccessKey": true, + "SessionToken": true, + "WebIdentityToken": true, +} + +// partiallyMaskedXMLElements lists XML element (and attribute) local names +// whose value is not itself a bearer credential but is still worth +// partially hiding. Only a short identifying prefix is left visible; see +// maskPartial. +var partiallyMaskedXMLElements = map[string]bool{ + "AccessKeyId": true, +} + +// maskPartial reveals only the first accessKeyVisiblePrefixLen characters +// of value, replacing the rest with redactedValue. Values no longer than +// the visible prefix are masked in full, so short values are never fully +// exposed. +func maskPartial(value string) string { + if len(value) <= accessKeyVisiblePrefixLen { + return redactedValue + } + return value[:accessKeyVisiblePrefixLen] + redactedValue +} + +// maskXMLValue returns the masked form of an XML element or attribute +// named name with text content value, per fullyMaskedXMLElements and +// partiallyMaskedXMLElements. It returns value unchanged when name isn't +// sensitive, or when unsafe is true (LevelUnsafe: print everything as-is). +func maskXMLValue(name, value string, unsafe bool) string { + if unsafe { + return value + } + if fullyMaskedXMLElements[name] { + return redactedValue + } + if partiallyMaskedXMLElements[name] { + return maskPartial(value) + } + return value +} + +// xmlNode is an in-memory XML element tree, used so the pretty-printer can +// decide per element whether to inline its text content or nest its +// children, and can mask leaf text without disturbing surrounding +// structure, namespaces, or attributes. +type xmlNode struct { + name string + space string // namespace URI; only rendered at the root + attrs []xml.Attr + text string + children []*xmlNode +} + +// maskXMLBody parses body as XML, and returns a pretty-printed copy with +// sensitive element and attribute values masked (per maskXMLValue), and ok +// true. If body is not well-formed XML, it returns (nil, false) and the +// caller should fall back to printing the raw bytes. +// +// The parse-then-render round trip preserves the full document structure +// (namespace, nesting, attributes) exactly, since every element still +// carries its original name, namespace, attributes, and children; only leaf +// text content matching a sensitive field name is replaced. +func maskXMLBody(body []byte) ([]byte, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '<' { + return nil, false + } + + dec := xml.NewDecoder(bytes.NewReader(body)) + root, xmlDecl, err := parseXMLTree(dec) + if err != nil { + return nil, false + } + + var out bytes.Buffer + if xmlDecl != "" { + out.WriteString(xmlDecl) + out.WriteByte('\n') + } + renderXMLNode(&out, root, 0, IsUnsafeEnabled()) + return out.Bytes(), true +} + +// parseXMLTree reads tokens from dec up to and including the document's +// single root element, returning that element as a tree and the raw XML +// declaration (e.g. ``) if present. +func parseXMLTree(dec *xml.Decoder) (*xmlNode, string, error) { + var xmlDecl string + for { + tok, err := dec.Token() + if err != nil { + return nil, "", err + } + switch t := tok.(type) { + case xml.ProcInst: + if t.Target == "xml" { + xmlDecl = fmt.Sprintf("", strings.TrimSpace(string(t.Inst))) + } + case xml.StartElement: + root, err := parseXMLElement(dec, t) + if err != nil { + return nil, "", err + } + return root, xmlDecl, nil + } + } +} + +// parseXMLElement reads dec until the matching end element for start, +// building the element subtree. +func parseXMLElement(dec *xml.Decoder, start xml.StartElement) (*xmlNode, error) { + n := &xmlNode{name: start.Name.Local, space: start.Name.Space} + for _, a := range start.Attr { + // xmlns / xmlns:* declarations are re-derived from Name.Space when + // rendering the root element; keep only "real" attributes here. + if a.Name.Space == "xmlns" || a.Name.Local == "xmlns" { + continue + } + n.attrs = append(n.attrs, a) + } + + var text bytes.Buffer + for { + tok, err := dec.Token() + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + child, err := parseXMLElement(dec, t) + if err != nil { + return nil, err + } + n.children = append(n.children, child) + case xml.EndElement: + n.text = text.String() + return n, nil + case xml.CharData: + text.Write(t) + } + } +} + +// renderXMLNode writes n to out at the given indent depth, masking leaf +// text and attribute values per maskXMLValue. +func renderXMLNode(out *bytes.Buffer, n *xmlNode, depth int, unsafe bool) { + out.WriteString(strings.Repeat(" ", depth)) + out.WriteByte('<') + out.WriteString(n.name) + if depth == 0 && n.space != "" { + fmt.Fprintf(out, ` xmlns="%s"`, escapeXML(n.space)) + } + for _, a := range n.attrs { + attrName := a.Name.Local + if a.Name.Space != "" { + attrName = a.Name.Space + ":" + attrName + } + fmt.Fprintf(out, ` %s="%s"`, attrName, escapeXML(maskXMLValue(a.Name.Local, a.Value, unsafe))) + } + + hasText := strings.TrimSpace(n.text) != "" + if len(n.children) == 0 && !hasText { + out.WriteString(">\n") + return + } + + out.WriteByte('>') + if len(n.children) > 0 { + out.WriteByte('\n') + for _, c := range n.children { + renderXMLNode(out, c, depth+1, unsafe) + } + out.WriteString(strings.Repeat(" ", depth)) + } else { + out.WriteString(escapeXML(maskXMLValue(n.name, n.text, unsafe))) + } + out.WriteString("\n") +} + +func escapeXML(s string) string { + var buf bytes.Buffer + // xml.EscapeText never returns an error for a bytes.Buffer destination. + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} diff --git a/debuglogger/xmlmask_test.go b/debuglogger/xmlmask_test.go new file mode 100644 index 00000000..bb13341c --- /dev/null +++ b/debuglogger/xmlmask_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package debuglogger + +import ( + "strings" + "testing" +) + +const stsBody = ` +AROAEXAMPLE:sessionarn:aws:sts::123456789012:assumed-role/role/sessionhttps://idp.example.comASIAabcdefghijklmnopsupersecretvalue1234567890tokentokentokentoken2026-07-30T12:00:00Zsubject-123req-123` + +func TestMaskXMLBodyMasksSecretsAtDebugLevel(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + out, ok := maskXMLBody([]byte(stsBody)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true for well-formed XML") + } + got := string(out) + + for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken"} { + if strings.Contains(got, secret) { + t.Errorf("masked output leaked secret %q:\n%s", secret, got) + } + } + if !strings.Contains(got, "****") { + t.Errorf("expected SecretAccessKey to be fully masked:\n%s", got) + } + if !strings.Contains(got, "****") { + t.Errorf("expected SessionToken to be fully masked:\n%s", got) + } + // AccessKeyId is partially masked: first 4 chars visible. + if !strings.Contains(got, "ASIA****") { + t.Errorf("expected AccessKeyId to be partially masked with prefix visible:\n%s", got) + } + // Non-sensitive fields must survive untouched. + for _, want := range []string{ + `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`, + "AROAEXAMPLE:session", + "arn:aws:sts::123456789012:assumed-role/role/session", + "https://idp.example.com", + "2026-07-30T12:00:00Z", + "req-123", + } { + if !strings.Contains(got, want) { + t.Errorf("expected masked output to preserve %q:\n%s", want, got) + } + } + // The namespace must be declared exactly once (on the root), not + // redeclared on every nested element. + if n := strings.Count(got, "xmlns="); n != 1 { + t.Errorf("expected exactly one xmlns declaration, got %d:\n%s", n, got) + } +} + +func TestMaskXMLBodyUnsafeLevelShowsSecrets(t *testing.T) { + SetLevel(LevelUnsafe) + defer SetLevel(LevelSilent) + + out, ok := maskXMLBody([]byte(stsBody)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true for well-formed XML") + } + got := string(out) + + for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken", "ASIAabcdefghijklmnop"} { + if !strings.Contains(got, secret) { + t.Errorf("unsafe-level output should show secret %q in the clear:\n%s", secret, got) + } + } +} + +func TestMaskXMLBodyPreservesNestingAndAttributes(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + body := `valuevalue2` + out, ok := maskXMLBody([]byte(body)) + if !ok { + t.Fatalf("maskXMLBody: expected ok=true") + } + got := string(out) + + if strings.Count(got, "") != 2 { + t.Errorf("expected both nested Inner elements to survive:\n%s", got) + } + if !strings.Contains(got, `id="1"`) { + t.Errorf("expected attribute to survive:\n%s", got) + } +} + +func TestMaskXMLBodyRejectsMalformedOrNonXML(t *testing.T) { + SetLevel(LevelDebug) + defer SetLevel(LevelSilent) + + for _, body := range []string{ + "", + " ", + "", + `{"json":"body"}`, + "plain text body", + } { + if _, ok := maskXMLBody([]byte(body)); ok { + t.Errorf("maskXMLBody(%q): expected ok=false", body) + } + } +} + +func TestMaskPartial(t *testing.T) { + tests := []struct { + value string + want string + }{ + {"AKIAabcdefghijklmnop", "AKIA****"}, + {"ASIA", "****"}, + {"abc", "****"}, + {"", "****"}, + } + for _, tt := range tests { + if got := maskPartial(tt.value); got != tt.want { + t.Errorf("maskPartial(%q) = %q, want %q", tt.value, got, tt.want) + } + } +} diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index b49d79eb..ba5e66ab 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -114,10 +114,13 @@ type Config struct { // (e.g. "https://webui.example.com") to restrict cross-origin access. CORSAllowOrigin string - // Debug enables verbose debug logging to stdout, including details for - // signature verification steps. Not intended for production use. - Debug bool - // IAMDebug enables verbose IAM subsystem debug logging. + // LogLevel controls the debug logger: LevelSilent (default) prints + // nothing, LevelDebug prints full request/response details with + // secrets and tokens masked, and LevelUnsafe prints them unmasked. + // Never use LevelUnsafe in production. + LogLevel debuglogger.Level + // IAMDebug enables verbose IAM subsystem debug logging. Has no effect + // when LogLevel is LevelSilent. IAMDebug bool // Quiet suppresses per-request summary logging to stdout. Quiet bool @@ -627,9 +630,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.S3Options) > 0 { opts = append(opts, cfg.S3Options...) } - if cfg.Debug { - debuglogger.SetDebugEnabled() - } + debuglogger.SetLevel(cfg.LogLevel) if cfg.IAMDebug { debuglogger.SetIAMDebugEnabled() } @@ -808,7 +809,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.Quiet { admOpts = append(admOpts, s3api.WithAdminQuiet()) } - if cfg.Debug { + if cfg.LogLevel != debuglogger.LevelSilent { admOpts = append(admOpts, s3api.WithAdminDebug()) } if cfg.SocketPerm != "" { diff --git a/embedgw/iam.go b/embedgw/iam.go index f6f087ef..5d638171 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -60,8 +60,11 @@ type IAMConfig struct { // KeyFile is the path to the TLS private key file for the IAM API server. KeyFile string - // Debug enables verbose request/response debug logging. - Debug bool + // LogLevel controls the debug logger: LevelSilent (default) prints + // nothing, LevelDebug prints full request/response details with + // secrets and tokens masked, and LevelUnsafe prints them unmasked. + // Never use LevelUnsafe in production. + LogLevel debuglogger.Level // Quiet suppresses per-request summary logging and startup output. Quiet bool // KeepAlive enables HTTP keep-alive on IAM API connections. @@ -208,9 +211,7 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.DisableOIDCThumbprintAutoFetch { opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) } - if cfg.Debug { - debuglogger.SetDebugEnabled() - } + debuglogger.SetLevel(cfg.LogLevel) if cfg.SocketPerm != "" { perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32) if err != nil { diff --git a/extra/example.conf b/extra/example.conf index 8f2f6c1d..4d37eed9 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -363,9 +363,28 @@ ROOT_SECRET_ACCESS_KEY= # Debug / Diagnostics # ####################### -# The VGW_DEBUG option enables verbose debug log output to stdout. This output -# includes details for signature verification steps. This is generally only -# useful for debugging the S3 server, and should not be used in production. +# The VGW_LOG_LEVEL option controls the verbosity and safety of the debug +# logger's output to stdout, which includes full request/response headers +# and bodies, and details for signature verification steps. It accepts one +# of the following values: +# silent - (default) no debug output. +# debug - full request/response logging, with secrets and tokens (e.g. +# access keys, secret keys, session tokens, signatures, SSE-C +# customer keys) masked at the property level. +# unsafe - full request/response logging with NO masking. Every secret +# and token is printed to stdout in the clear. +# +# WARNING: be very careful with VGW_LOG_LEVEL=unsafe. It logs account +# secrets, session tokens, and other credentials to the console with no +# masking at all -- anyone who can read that output can replay them +# directly. Only use "unsafe" for local troubleshooting on a trusted +# machine, and never in production. +#VGW_LOG_LEVEL=silent + +# The VGW_DEBUG option is a deprecated alias for VGW_LOG_LEVEL=debug, kept +# only for backward compatibility. Setting it to true prints a deprecation +# warning to the console and enables debug-level logging; use VGW_LOG_LEVEL +# instead for finer-grained control (including "unsafe" mode). #VGW_DEBUG=false # The VGW_PPROF option enables the pprof HTTP server for profiling the S3 diff --git a/go.mod b/go.mod index ca9de513..6c4cab89 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/versity/versitygw go 1.25.0 +toolchain go1.26.5 + require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 @@ -13,11 +15,13 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.12 github.com/aws/aws-sdk-go-v2/service/iam v1.54.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 + github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 github.com/aws/smithy-go v1.27.3 github.com/cespare/xxhash/v2 v2.3.0 github.com/davecgh/go-spew v1.1.1 github.com/go-ldap/ldap/v3 v3.4.13 github.com/gofiber/fiber/v3 v3.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/vault-client-go v0.4.3 @@ -56,12 +60,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/gofiber/schema v1.8.0 // indirect github.com/gofiber/utils/v2 v2.1.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect @@ -85,6 +87,6 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index b25b1fc8..f601402c 100644 --- a/go.sum +++ b/go.sum @@ -242,8 +242,8 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/iamapi/authentication_test.go b/iamapi/authentication_test.go index fa3031cf..a99b5ed0 100644 --- a/iamapi/authentication_test.go +++ b/iamapi/authentication_test.go @@ -22,6 +22,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "regexp" "strings" "testing" @@ -305,6 +306,25 @@ func TestVerifyIAMAuthRejectsUnsignedQueryParameter(t *testing.T) { requireIAMError(t, resp, want.HTTPStatusCode, string(want.Type), want.Code, want.Message) } +// TestVerifyIAMAuthRejectsRootQueryAuthWithSecurityToken confirms a security +// token tacked onto a root-signed presigned request is rejected outright +// (InvalidClientTokenId) rather than falling through to a +// signature-mismatch error — root's own access key is never a temporary +// one, so it can never legitimately carry a security token at all. +func TestVerifyIAMAuthRejectsRootQueryAuthWithSecurityToken(t *testing.T) { + app := newIAMAuthTestApp(t) + req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC()) + query := req.URL.Query() + query.Set(sigv4auth.QuerySecurityToken, "bogus-token") + req.URL.RawQuery = query.Encode() + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) { app := newIAMAuthTestApp(t) req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", nil, testRoot.Secret, "us-west-2", time.Now().UTC()) @@ -317,6 +337,105 @@ func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) { requireIAMError(t, resp, http.StatusForbidden, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to a valid region. ") } +// TestVerifyIAMAuthRejectsExpiredQueryRequest confirms a presigned IAM +// request signed too long ago is rejected by the same fixed ±15-minute +// freshness window (ValidateDateAt) header auth uses — confirmed live +// (niksis02 profile): real IAM's query-auth ignores X-Amz-Expires entirely +// (see TestVerifyIAMAuthQueryIgnoresXAmzExpires) and instead rejects a +// stale signing time with SignatureDoesNotMatch: "Signature expired: ... +// is now earlier than ... (... - 15 min.)" — byte-for-byte what this +// codebase's own SignatureDoesNotMatchExpired already produces. +func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) { + app := newIAMAuthTestApp(t) + signedTwoHoursAgo := time.Now().UTC().Add(-2 * time.Hour) + req := querySignedIAMRequest(t, http.MethodGet, "http://example.com/?Action=ListUsers&Version=2010-05-08", + nil, testRoot.Secret, iammiddleware.SigningRegion, signedTwoHoursAgo) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + + var errResp struct { + XMLName xml.Name `xml:"ErrorResponse"` + Error struct { + Type string + Code string + } + } + body := readBody(t, resp) + if err := xml.Unmarshal([]byte(body), &errResp); err != nil { + t.Fatalf("unmarshal IAM error: %v\n%s", err, body) + } + if resp.StatusCode != http.StatusForbidden || errResp.Error.Type != "Sender" || errResp.Error.Code != "SignatureDoesNotMatch" { + t.Fatalf("status=%d error=%#v, want 403 Sender/SignatureDoesNotMatch; body=%s", resp.StatusCode, errResp.Error, body) + } +} + +// TestVerifyIAMAuthQueryIgnoresXAmzExpires confirms IAM/STS query-auth +// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs +// — confirmed live (niksis02 profile) that real IAM's ListUsers accepts a +// presigned request with X-Amz-Expires omitted, non-numeric, negative, or +// far beyond S3's 604800-second maximum, every time. +func TestVerifyIAMAuthQueryIgnoresXAmzExpires(t *testing.T) { + for _, expires := range []string{"", "abc", "-5", "9999999"} { + t.Run(expires, func(t *testing.T) { + app := newIAMAuthTestApp(t) + target := "http://example.com/?Action=ListUsers&Version=2010-05-08" + if expires != "" { + target += "&X-Amz-Expires=" + expires + } + req := querySignedIAMRequest(t, http.MethodGet, target, nil, testRoot.Secret, iammiddleware.SigningRegion, time.Now().UTC()) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, readBody(t, resp)) + } + }) + } +} + +// TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned confirms a temporary +// (ASIA…) session's X-Amz-Security-Token header must itself be part of +// SignedHeaders — present-but-unsigned is now rejected instead of being +// silently dropped from the canonical request (see +// requiredHeaderAuthSignedHeaders). Before this fix, this exact request +// (correct token value, correct signature, token simply excluded from +// SignedHeaders) would have authenticated successfully. +func TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-tokenheader", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + body := []byte(url.Values{"Action": {"GetUser"}, "Version": {iamAPIVersion}}.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(sigv4auth.HeaderSecurityToken, session.SessionToken) + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + signer := vgwv4.NewSigner() + // Sign with only "host" listed — the security-token header is present + // on the wire but deliberately excluded from SignedHeaders, simulating + // a client (or tampering party) that never binds it to the signature. + if _, err := signer.SignHTTP(context.Background(), + aws.Credentials{AccessKeyID: session.AccessKeyId, SecretAccessKey: session.SecretAccessKey}, + req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC(), []string{"host"}); err != nil { + t.Fatalf("sign request: %v", err) + } + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "IncompleteSignature", + "The request signature does not conform to AWS standards. Header(s) not signed: x-amz-security-token.") +} + func TestVerifyIAMAuthRejectsMissingAuthorization(t *testing.T) { app := newIAMAuthTestApp(t) @@ -511,7 +630,7 @@ func newIAMAuthTestApp(t *testing.T) *fiber.App { func(ctx fiber.Ctx) (*Response, error) { return &Response{Status: http.StatusOK}, nil }, - iammiddleware.VerifyIAMAuth(&testRoot), + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceIAM, &testRoot, nil), )) return app } diff --git a/iamapi/authorization_test.go b/iamapi/authorization_test.go new file mode 100644 index 00000000..60730a78 --- /dev/null +++ b/iamapi/authorization_test.go @@ -0,0 +1,602 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamapi + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/versity/versitygw/iamapi/internal/iammiddleware" + iamtypes "github.com/versity/versitygw/iamapi/types" +) + +// signedIAMActionAs signs params (as an "iam"-service request, matching +// every non-STS action) with an arbitrary access key/secret/session token, +// unlike signedIAMRequest/querySignedIAMRequest which always sign as root. +func signedIAMActionAs(t *testing.T, access, secret, sessionToken string, params url.Values) *http.Request { + t.Helper() + if !params.Has("Version") { + params.Set("Version", iamAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC()); err != nil { + t.Fatalf("sign iam request: %v", err) + } + return req +} + +func doSignedIAMActionAs(t *testing.T, server *IAMApiServer, access, secret, sessionToken string, params url.Values) *http.Response { + t.Helper() + req := signedIAMActionAs(t, access, secret, sessionToken, params) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// createTestUserWithAccessKey creates a user (and, if policyDocument != "", +// an inline policy for it) via root, and an access key for it, returning the +// key material tests sign requests with. +func createTestUserWithAccessKey(t *testing.T, server *IAMApiServer, userName, policyDocument string) (accessKeyID, secretAccessKey string) { + t.Helper() + + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {userName}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + if policyDocument != "" { + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {userName}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {policyDocument}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + + resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {userName}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateAccessKeyResponse + unmarshalXML(t, readBody(t, resp), &out) + return out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey +} + +func TestVerifyIAMPolicyAllowsGrantedAction(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "alice", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func TestVerifyIAMPolicyDeniesUngrantedAction(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "bob", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"CreateUser"}, "UserName": {"carol"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/bob is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicyDeniesUserWithNoPolicies(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "dave", "") + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"dave"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/dave is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +func TestVerifyIAMAuthRejectsInactiveAccessKey(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "erin", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`) + + resp := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAccessKey"}, + "UserName": {"erin"}, + "AccessKeyId": {accessKeyID}, + "Status": {"Inactive"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("UpdateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"erin"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestVerifyIAMAuthRejectsUnknownAccessKey(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doSignedIAMActionAs(t, server, "unknown-access-key-id", "does-not-matter", "", url.Values{"Action": {"ListUsers"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestIAMApiControllerGetCallerIdentityWithUser(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "frank", "") + + resp := doSignedSTSAction(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:iam::000000000000:user/frank" { + t.Fatalf("GetCallerIdentity user Arn = %q", out.Result.Arn) + } + if out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity user Account = %q", out.Result.Account) + } +} + +// createTestSession creates a role with rolePolicyDocument as its sole +// inline policy and directly stores a session assuming it (bypassing +// AssumeRoleWithWebIdentity's OIDC token verification, which needs a live +// provider) carrying sessionPolicyDocument as its session policy. +func createTestSession(t *testing.T, server *IAMApiServer, roleName, rolePolicyDocument, sessionPolicyDocument string) iamtypes.Session { + t.Helper() + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var createRoleOut iamtypes.CreateRoleResponse + unmarshalXML(t, readBody(t, resp), &createRoleOut) + role := createRoleOut.Result.Role + + resp = doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {roleName}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {rolePolicyDocument}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIATEST" + roleName, + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: role.Arn, + RoleName: roleName, + RoleID: role.RoleID, + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + Policy: sessionPolicyDocument, + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + return session +} + +func TestVerifyIAMPolicySessionUsesRolePolicy(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + session := createTestSession(t, server, "role-a", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + // UserName names an existing user (rather than the caller's own + // self-lookup form) so this specifically exercises the role's + // identity-based policy granting iam:GetUser, independent of GetUser's + // separate self-lookup-vs-named-lookup behavior. + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser (role-granted) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-a/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicySessionPolicyCanOnlyNarrowRolePermissions(t *testing.T) { + server := newIAMControllerTestServer(t) + // The role broadly allows both actions; the session policy only allows + // one of them. Effective permissions = role ∩ session policy, so the + // narrower session policy is what actually governs. + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + session := createTestSession(t, server, "role-b", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:GetUser","iam:CreateUser"],"Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser (allowed by both) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-b/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +func TestVerifyIAMPolicyResourceScopedAllowDeniesDifferentResource(t *testing.T) { + server := newIAMControllerTestServer(t) + + for _, roleName := range []string{"role-x", "role-y"} { + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole(%s) status = %d, body=%s", roleName, resp.StatusCode, readBody(t, resp)) + } + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "gina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-x"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-x"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetRole(role-x) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + // The policy only names role-x's ARN as Resource; a request for role-y + // must not be authorized by it, even though the Action matches. + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-y"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/gina is not authorized to perform: iam:GetRole because no identity-based policy allows the iam:GetRole action") +} + +func TestVerifyIAMPolicySessionDeniedWhenStoredRoleIDNoLongerMatches(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-mismatch", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + // Simulate the role having been deleted and recreated (getting a new + // RoleID) while this session, minted against the old role, is still + // unexpired: mutate the stored session's RoleID so it no longer matches + // the role currently on record. + stale := session + stale.RoleID = "AROASTALEROLEID" + if _, err := server.store.CreateSession(context.Background(), stale); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedIAMActionAs(t, server, stale.AccessKeyId, stale.SecretAccessKey, stale.SessionToken, + url.Values{"Action": {"GetUser"}, "UserName": {""}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-mismatch/my-session is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +func TestVerifyIAMPolicySessionPolicyCannotWidenRolePermissions(t *testing.T) { + server := newIAMControllerTestServer(t) + // The role only allows GetUser; a broad session policy cannot grant + // CreateUser on top of that. + session := createTestSession(t, server, "role-c", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:sts::000000000000:assumed-role/role-c/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") +} + +// TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource +// exercises the two-resource nature of a rename/path-move: AWS's UpdateUser +// requires permission on both the source object and the object being moved +// to (see the UpdateUser API's documented "Note" on required permissions). +// A policy scoped only to the source path must not authorize moving the +// user out of it. +func TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "irene", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":"arn:aws:iam::000000000000:user/developers/*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", + url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/irene is not authorized to perform: iam:UpdateUser because no identity-based policy allows the iam:UpdateUser action") +} + +// TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources is the +// positive counterpart: once the policy names both the source and the +// target ARN, the same rename/path-move succeeds. +func TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources(t *testing.T) { + server := newIAMControllerTestServer(t) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "judy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":["arn:aws:iam::000000000000:user/developers/alice","arn:aws:iam::000000000000:user/admins/alice"]}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", + url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("UpdateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyGetUserSelfLookupResourceScoped guards against +// GetUser's omitted-UserName ("look up my own identity") form resolving to +// "*" instead of the caller's own ARN: with only a wildcard fallback, a +// Resource-scoped policy naming the caller's own ARN could never authorize +// their own self-lookup, forcing callers to be granted Resource:"*" just to +// use the feature. +func TestVerifyIAMPolicyGetUserSelfLookupResourceScoped(t *testing.T) { + server := newIAMControllerTestServer(t) + + hankAccessKeyID, hankSecret := createTestUserWithAccessKey(t, server, "hank", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`) + + resp := doSignedIAMActionAs(t, server, hankAccessKeyID, hankSecret, "", url.Values{"Action": {"GetUser"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(self) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + ivyAccessKeyID, ivySecret := createTestUserWithAccessKey(t, server, "ivy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`) + + // A policy scoped to hank's ARN must not authorize ivy's self-lookup, + // which resolves against ivy's own ARN, not hank's. + resp = doSignedIAMActionAs(t, server, ivyAccessKeyID, ivySecret, "", url.Values{"Action": {"GetUser"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/ivy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped guards against +// GetAccessKeyLastUsed (which carries only AccessKeyId, never UserName) +// falling back to "*" instead of resolving the queried key's owning user: +// with only a wildcard fallback, a Resource-scoped policy could never +// authorize the action at all, and — once granted via Resource:"*" — could +// not stop a caller from looking up any other user's key. +func TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped(t *testing.T) { + server := newIAMControllerTestServer(t) + + ninaAccessKeyID, ninaSecret := createTestUserWithAccessKey(t, server, "nina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetAccessKeyLastUsed","Resource":"arn:aws:iam::000000000000:user/nina"}]}`) + + resp := doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "", + url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {ninaAccessKeyID}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetAccessKeyLastUsed(own key) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + oscarAccessKeyID, _ := createTestUserWithAccessKey(t, server, "oscar", "") + + // nina's policy only names her own ARN as Resource; it must not + // authorize looking up oscar's access key, even though the Action + // matches — the resource-level check resolves AccessKeyId to its + // owning user, not a wildcard. + resp = doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "", + url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {oscarAccessKeyID}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/nina is not authorized to perform: iam:GetAccessKeyLastUsed because no identity-based policy allows the iam:GetAccessKeyLastUsed action") +} + +func TestVerifyIAMPolicySecureTransportDenyAppliesToPlaintextRequest(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "paul", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"paul"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/paul is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive verifies that +// condition-key lookup treats key *names* (unlike their values) as +// case-insensitive, so a Deny written against this package's internal +// aws:SourceIp key using different casing is still evaluated, not silently +// treated as naming an absent key. +func TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "quinn", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"AWS:SOURCEIP":"false"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"quinn"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/quinn is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyPermanentUserHasUserId verifies that aws:userid is +// populated for a long-term IAM user principal, not only for a session (AWS +// sets aws:username and aws:userid simultaneously). A Deny guarding on its +// absence must not fire for a permanent user. +func TestVerifyIAMPolicyPermanentUserHasUserId(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "ray", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"aws:userid":"true"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"ray"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable verifies that +// ${aws:username} in a statement's Resource is substituted before matching, +// so a Deny scoped to the caller's own resource via this variable matches +// the actual resource ARN instead of letting the broader Allow win. +func TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "sam", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"sam"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/sam is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") +} + +// TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition verifies that +// aws:RequestTag/ and aws:TagKeys are populated from a Create action's +// own Tags parameter, so a Deny guarding against a specific tag value blocks +// the tagged create. +func TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "tina", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"StringEquals":{"aws:RequestTag/env":"prod"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{ + "Action": {"CreateUser"}, + "UserName": {"newbie"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/tina is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action") + + // A different tag value doesn't match the Deny's condition, so creation + // proceeds - confirming the Deny above was tag-value-specific, not a + // blanket denial of tagged creates. + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{ + "Action": {"CreateUser"}, + "UserName": {"newbie2"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"dev"}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(env=dev) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource verifies that +// iam:ResourceTag/ (and, identically, the generic aws:ResourceTag/) +// is hydrated from an existing target resource's own stored tags, so a Deny +// guarding on it overrides the broad Allow underneath it when the target +// carries that tag. +func TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource(t *testing.T) { + server := newIAMControllerTestServer(t) + + // victor is the tagged target; his tag is set at creation time, via root. + if resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"victor"}, + "Tags.member.1.Key": {"sensitive"}, + "Tags.member.1.Value": {"true"}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(victor) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + accessKeyID, secret := createTestUserWithAccessKey(t, server, "wendy", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"iam:ResourceTag/sensitive":"true"}}}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/wendy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // The generic aws:ResourceTag/ form is populated identically to the + // iam:ResourceTag/ one. + accessKeyID2, secret2 := createTestUserWithAccessKey(t, server, "xander", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:ResourceTag/sensitive":"true"}}}]}`) + resp = doSignedIAMActionAs(t, server, accessKeyID2, secret2, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/xander is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // An untagged user isn't affected by either Deny. + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"yolanda"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(yolanda) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"yolanda"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(yolanda, untagged) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +// TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller verifies that +// aws:PrincipalTag/ is hydrated from the *calling* user's own stored +// tags, so a Deny guarding on it overrides the broad Allow underneath it +// when the caller carries that tag. +func TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller(t *testing.T) { + server := newIAMControllerTestServer(t) + + if resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"zack"}, + "Tags.member.1.Key": {"team"}, + "Tags.member.1.Value": {"contractor"}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + if resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"zack"}, + "PolicyName": {"test-policy"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},` + + `{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`}, + }); resp.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"zack"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateAccessKeyResponse + unmarshalXML(t, readBody(t, resp), &out) + + resp = doSignedIAMActionAs(t, server, out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey, "", url.Values{"Action": {"GetUser"}, "UserName": {"zack"}}) + requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", + "User: arn:aws:iam::000000000000:user/zack is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action") + + // A caller without that tag isn't affected by the same policy shape. + untaggedAccessKeyID, untaggedSecret := createTestUserWithAccessKey(t, server, "abby", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`) + resp = doSignedIAMActionAs(t, server, untaggedAccessKeyID, untaggedSecret, "", url.Values{"Action": {"GetUser"}, "UserName": {"abby"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser(abby, untagged principal) status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} diff --git a/iamapi/controller.go b/iamapi/controller.go index 0fc3a5a3..5b06eef1 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,6 +17,7 @@ package iamapi import ( "errors" "fmt" + "slices" "time" "github.com/gofiber/fiber/v3" @@ -26,6 +27,7 @@ import ( "github.com/versity/versitygw/iamapi/policy" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/httpctx" ) type IAMApiController struct { @@ -115,17 +117,24 @@ func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) { func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) { username, ok := iamutil.RequestParam(ctx, "UserName") - if !ok { - debuglogger.Logf("missing required GetUser parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if username == "" { - return &Response{Data: &types.GetUserResponse{ - Result: types.GetUserResult{User: types.User{ - UserID: iamutil.DefaultAccountID, - Arn: fmt.Sprintf("arn:aws:iam::%s:root", iamutil.DefaultAccountID), - }}, - }}, nil + if !ok || username == "" { + // Real IAM treats an omitted UserName as "look up the caller's own identity + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + switch { + case identity.IsRoot: + return &Response{Data: &types.GetUserResponse{ + Result: types.GetUserResult{User: types.User{ + UserID: iamutil.DefaultAccountID, + Arn: fmt.Sprintf("arn:aws:iam::%s:root", iamutil.DefaultAccountID), + }}, + }}, nil + case identity.User != nil: + return &Response{Data: &types.GetUserResponse{ + Result: types.GetUserResult{User: *identity.User}, + }}, nil + default: + return nil, iamerr.ValidationError("Must specify userName when calling with non-User credentials") + } } if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err @@ -1042,3 +1051,255 @@ func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) ( return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil } + +func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, error) { + rawRoleArn, ok := iamutil.RequestParam(ctx, "RoleArn") + if !ok || rawRoleArn == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: RoleArn") + return nil, iamerr.MissingValue("roleArn") + } + if err := iamutil.ValidateRoleArnLength(rawRoleArn); err != nil { + return nil, err + } + + roleSessionName, ok := iamutil.RequestParam(ctx, "RoleSessionName") + if !ok || roleSessionName == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: RoleSessionName") + return nil, iamerr.MissingValue("roleSessionName") + } + if err := iamutil.ValidateRoleSessionName(roleSessionName); err != nil { + return nil, err + } + + webIdentityToken, ok := iamutil.RequestParam(ctx, "WebIdentityToken") + if !ok || webIdentityToken == "" { + debuglogger.Logf("missing required AssumeRoleWithWebIdentity parameter: WebIdentityToken") + return nil, iamerr.MissingValue("webIdentityToken") + } + if err := iamutil.ValidateWebIdentityTokenLength(webIdentityToken); err != nil { + return nil, err + } + + // PolicyArns (managed session policies) and ProviderId (legacy Login + // with Amazon support) are valid AssumeRoleWithWebIdentity parameters + // this implementation doesn't enforce. Rejecting them outright, rather + // than silently accepting and ignoring them + if iamutil.HasRequestParamPrefix(ctx, "PolicyArns.member.") { + debuglogger.Logf("AssumeRoleWithWebIdentity: PolicyArns is not supported") + return nil, iamerr.UnsupportedParameter("PolicyArns") + } + if providerID, ok := iamutil.RequestParam(ctx, "ProviderId"); ok && providerID != "" { + debuglogger.Logf("AssumeRoleWithWebIdentity: ProviderId is not supported") + return nil, iamerr.UnsupportedParameter("ProviderId") + } + + durationSeconds, err := iamutil.ParseDurationSeconds(ctx) + if err != nil { + return nil, err + } + + // sessionPolicy is an optional additional permissions filter on top of + // the assumed role's own policies (Effective permissions = Role + // identity-based permissions ∩ Session policy permissions, enforced by + // iammiddleware.VerifyIAMPolicy); it uses identity-policy grammar, not + // trust-policy grammar, same as PutUserPolicy/PutRolePolicy. + sessionPolicy, ok := iamutil.RequestParam(ctx, "Policy") + if ok && sessionPolicy != "" { + if len(sessionPolicy) > policy.MaxSessionPolicyBytes { + return nil, iamerr.ValueTooLong("policy", policy.MaxSessionPolicyBytes) + } + if err := policy.Validate("policy", sessionPolicy); err != nil { + return nil, err + } + if err := policy.Parse(sessionPolicy); err != nil { + return nil, err + } + } + + // Structural JWT parsing happens before the role is even looked up — + // a malformed token is rejected the same way regardless of whether + // RoleArn names a real role. + claims, err := iamutil.ParseWebIdentityClaims(webIdentityToken) + if err != nil { + return nil, err + } + + roleName, ok := iamutil.RoleNameFromAssumeArn(rawRoleArn, iamutil.DefaultAccountID) + if !ok { + debuglogger.Logf("AssumeRoleWithWebIdentity: RoleArn is not a role in this account: %q", rawRoleArn) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + role, err := c.store.GetRole(ctx.Context(), roleName) + if err != nil { + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q not found: %v", roleName, err) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + // RoleNameFromAssumeArn only extracted the final path segment; confirm + // the full ARN the caller supplied — path included — actually matches + // this role's own Arn. Without this, an ARN naming the right role name + // but a different (or missing) path would still resolve to, and assume, + // this role. + if rawRoleArn != role.Arn { + debuglogger.Logf("AssumeRoleWithWebIdentity: RoleArn %q does not match role %q's actual arn %q", rawRoleArn, roleName, role.Arn) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + if role.MaxSessionDuration > 0 && durationSeconds > role.MaxSessionDuration { + debuglogger.Logf("AssumeRoleWithWebIdentity: requested duration %ds exceeds role %q max session duration %ds", durationSeconds, roleName, role.MaxSessionDuration) + return nil, iamerr.DurationExceedsMaxSessionDuration() + } + + issuer, ok := iamutil.WebIdentityIssuer(claims) + if !ok { + debuglogger.Logf("AssumeRoleWithWebIdentity: token has no iss claim") + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + + audience, originalAudience, err := iamutil.WebIdentityAudience(claims) + if err != nil { + return nil, err + } + + subject, _ := claims["sub"].(string) + rawIssuer, _ := claims["iss"].(string) + + now := time.Now().UTC().Truncate(time.Second) + wctx := policy.WebIdentityContext{ + ProviderURL: issuer, + Audience: audience, + OriginalAudience: originalAudience, + Subject: subject, + Claims: iamutil.ExtractClaimContext(claims), + SourceIP: ctx.IP(), + Secure: ctx.Secure(), + Now: now, + RoleSessionName: roleSessionName, + } + + lookup := func(federatedArn string) (string, bool) { + provider, err := c.store.GetOIDCProvider(ctx.Context(), federatedArn) + if err != nil { + return "", false + } + return provider.Url, true + } + + result, providerArn := policy.EvaluateWebIdentityTrust(role.AssumeRolePolicyDocument, lookup, wctx) + switch result { + case policy.NoPrincipal, policy.ExplicitlyDenied: + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q trust policy does not authorize this request", roleName) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + case policy.NoIssuerMatch, policy.ConditionFailed: + debuglogger.Logf("AssumeRoleWithWebIdentity: role %q trust policy rejected the token's claims", roleName) + return nil, iamerr.InvalidIdentityTokenClaims() + } + + provider, err := c.store.GetOIDCProvider(ctx.Context(), providerArn) + if err != nil { + debuglogger.Logf("AssumeRoleWithWebIdentity: matched provider %q vanished before use: %v", providerArn, err) + return nil, iamerr.AccessDeniedAssumeRoleWithWebIdentity() + } + if len(provider.ClientIDList) == 0 || !slices.Contains(provider.ClientIDList, audience) { + debuglogger.Logf("AssumeRoleWithWebIdentity: audience %q not in provider %q ClientIDList", audience, providerArn) + return nil, iamerr.InvalidIdentityTokenClaims() + } + + verifiedClaims, err := iamutil.VerifyWebIdentitySignature(ctx.Context(), webIdentityToken, provider.Url, provider.ThumbprintList) + if err != nil { + return nil, err + } + if err := iamutil.VerifyWebIdentityExpiration(verifiedClaims, now); err != nil { + return nil, err + } + if err := iamutil.VerifyWebIdentityRequiredClaims(verifiedClaims, now); err != nil { + return nil, err + } + + accessKeyID, err := iamutil.GenerateTempAccessKeyID() + if err != nil { + return nil, err + } + secretAccessKey, err := iamutil.GenerateSecretAccessKey() + if err != nil { + return nil, err + } + sessionToken, err := iamutil.GenerateSessionToken() + if err != nil { + return nil, err + } + + expiration := now.Add(time.Duration(durationSeconds) * time.Second) + + session := types.Session{ + AccessKeyId: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + RoleArn: role.Arn, + RoleName: role.RoleName, + RoleID: role.RoleID, + RoleSessionName: roleSessionName, + Provider: providerArn, + Audience: audience, + Subject: subject, + CreateDate: now, + Expiration: expiration, + Policy: sessionPolicy, + } + if _, err := c.store.CreateSession(ctx.Context(), session); err != nil { + debuglogger.Logf("failed to store AssumeRoleWithWebIdentity session for access key %q: %v", accessKeyID, err) + return nil, err + } + + return &Response{Data: &types.AssumeRoleWithWebIdentityResponse{ + Result: types.AssumeRoleWithWebIdentityResult{ + Audience: audience, + AssumedRoleUser: types.AssumedRoleUser{ + AssumedRoleId: role.RoleID + ":" + roleSessionName, + Arn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, roleSessionName), + }, + Provider: rawIssuer, + Credentials: types.Credentials{ + AccessKeyId: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + Expiration: expiration, + }, + SubjectFromWebIdentityToken: subject, + PackedPolicySize: iamutil.PackedPolicySize(sessionPolicy), + }, + }}, nil +} + +func (c IAMApiController) GetCallerIdentity(ctx fiber.Ctx) (*Response, error) { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + + switch { + case identity.Session != nil: + session := identity.Session + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, session.RoleName, session.RoleSessionName), + UserId: session.RoleID + ":" + session.RoleSessionName, + Account: iamutil.DefaultAccountID, + }, + }}, nil + case identity.User != nil: + user := identity.User + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: user.Arn, + UserId: user.UserID, + Account: iamutil.DefaultAccountID, + }, + }}, nil + default: + return &Response{Data: &types.GetCallerIdentityResponse{ + Result: types.GetCallerIdentityResult{ + Arn: fmt.Sprintf("arn:aws:iam::%s:root", iamutil.DefaultAccountID), + UserId: iamutil.DefaultAccountID, + Account: iamutil.DefaultAccountID, + }, + }}, nil + } +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 0f80d9d1..2f12d765 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -14,8 +14,15 @@ package iamapi import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" "encoding/xml" "net/http" + "net/http/httptest" "net/url" "regexp" "slices" @@ -23,11 +30,15 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go-v2/aws" + awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" iamtypes "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/sigv4auth" ) var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`) @@ -157,30 +168,72 @@ func TestIAMApiControllerUserLifecycle(t *testing.T) { requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user with name zoe cannot be found.") } +// TestIAMApiControllerGetRootUser confirms GetUser's self-lookup form +// (UserName omitted, the only way any real AWS SDK/CLI ever invokes it, +// since Query-protocol clients simply don't serialize an absent optional +// field — confirmed live: `aws iam get-user` with no --user-name, as root, +// succeeds and returns the root pseudo-user) and its non-standard explicit- +// empty-string equivalent both resolve to the actual authenticated caller — +// root, here, since doIAMAction always signs as root. func TestIAMApiControllerGetRootUser(t *testing.T) { server := newIAMControllerTestServer(t) - resp := doIAMAction(t, server, url.Values{ - "Action": {"GetUser"}, - "UserName": {""}, - }) + + for _, params := range []url.Values{ + {"Action": {"GetUser"}}, + {"Action": {"GetUser"}, "UserName": {""}}, + } { + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetUser root (params=%v) status = %d, body=%s", params, resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetUserResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.User.UserID != iamutil.DefaultAccountID { + t.Fatalf("GetUser root UserId = %q, want %q", out.Result.User.UserID, iamutil.DefaultAccountID) + } + if out.Result.User.Arn != "arn:aws:iam::000000000000:root" { + t.Fatalf("GetUser root Arn = %q", out.Result.User.Arn) + } + if out.ResponseMetadata.RequestID == "" { + t.Fatal("GetUser root missing RequestId") + } + } +} + +// TestIAMApiControllerGetUserSelfLookupNonRoot confirms GetUser's +// self-lookup form resolves to the actual authenticated non-root caller — +// not always root, which was the bug this test guards against. +func TestIAMApiControllerGetUserSelfLookupNonRoot(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "ivan", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`) + + resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}}) if resp.StatusCode != http.StatusOK { - t.Fatalf("GetUser root status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + t.Fatalf("GetUser self-lookup status = %d, body=%s", resp.StatusCode, readBody(t, resp)) } var out iamtypes.GetUserResponse unmarshalXML(t, readBody(t, resp), &out) - if out.Result.User.UserID != iamutil.DefaultAccountID { - t.Fatalf("GetUser root UserId = %q, want %q", out.Result.User.UserID, iamutil.DefaultAccountID) - } - if out.Result.User.Arn != "arn:aws:iam::000000000000:root" { - t.Fatalf("GetUser root Arn = %q", out.Result.User.Arn) - } - if out.ResponseMetadata.RequestID == "" { - t.Fatal("GetUser root missing RequestId") + if out.Result.User.UserName != "ivan" || out.Result.User.Arn != "arn:aws:iam::000000000000:user/ivan" { + t.Fatalf("GetUser self-lookup = %#v, want caller's own identity (ivan)", out.Result.User) } +} - missing := doIAMAction(t, server, url.Values{"Action": {"GetUser"}}) - requireIAMError(t, missing, http.StatusBadRequest, "Sender", "MissingParameter", "The request must contain the parameter UserName.") +// TestIAMApiControllerGetUserSelfLookupSessionRejected confirms an assumed- +// role session — which has no IAM user identity to self-look-up — gets +// AWS's own ValidationError rather than being told it's root or some +// arbitrary user. +func TestIAMApiControllerGetUserSelfLookupSessionRejected(t *testing.T) { + server := newIAMControllerTestServer(t) + session := createTestSession(t, server, "role-selflookup", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "") + + resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetUser"}}) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "Must specify userName when calling with non-User credentials") } func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { @@ -2064,3 +2117,956 @@ func requireUserTags(t *testing.T, tags []iamtypes.Tag) { t.Fatalf("Tags = %#v, want env=test and empty=", tags) } } + +// requireSTSError is requireIAMError's counterpart for the two STS actions: +// their errors render under STS's namespace instead of IAM's, except +// InvalidAction (a request whose Version doesn't resolve to any known +// action, so there's no specific service to attribute the fault to yet), +// which always uses the generic AWS fault namespace. +func requireSTSError(t *testing.T, resp *http.Response, status int, errType, code, message string) { + t.Helper() + + body := readBody(t, resp) + if resp.StatusCode != status { + t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, status, body) + } + + var errResp struct { + XMLName xml.Name `xml:"ErrorResponse"` + Error struct { + Type string + Code string + Message string + } + RequestID string `xml:"RequestId"` + } + if err := xml.Unmarshal([]byte(body), &errResp); err != nil { + t.Fatalf("unmarshal STS error: %v\n%s", err, body) + } + + wantNamespace := iamerr.STSNamespace + if code == "InvalidAction" { + wantNamespace = iamerr.AWSFaultNamespace + } + if errResp.XMLName.Space != wantNamespace { + t.Fatalf("namespace = %q, want %q", errResp.XMLName.Space, wantNamespace) + } + if errResp.Error.Type != errType || errResp.Error.Code != code || errResp.Error.Message != message { + t.Fatalf("error = %#v, want type=%q code=%q message=%q", errResp.Error, errType, code, message) + } + if errResp.RequestID == "" { + t.Fatal("missing RequestId") + } +} + +// doSTSAction sends params as an unsigned POST request — every one of +// these tests either exercises AssumeRoleWithWebIdentity (which requires no +// credentials at all) or deliberately omits auth to check the resulting +// error, so signing is opt-in via signedSTSRequest instead of the default. +func doSTSAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Response { + t.Helper() + if !params.Has("Version") { + params.Set("Version", stsAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// signedSTSRequest builds an STS-style request (Credential scoped to +// "sts", matching a real STS SDK client) signed with the given +// credentials, optionally carrying an X-Amz-Security-Token header for +// temporary credentials. +func signedSTSRequest(t *testing.T, access, secret, sessionToken string, params url.Values) *http.Request { + t.Helper() + if !params.Has("Version") { + params.Set("Version", stsAPIVersion) + } + + body := []byte(params.Encode()) + req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + hash := sha256.Sum256(body) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "sts", iammiddleware.SigningRegion, time.Now().UTC()); err != nil { + t.Fatalf("sign sts request: %v", err) + } + return req +} + +func doSignedSTSAction(t *testing.T, server *IAMApiServer, access, secret, sessionToken string, params url.Values) *http.Response { + t.Helper() + req := signedSTSRequest(t, access, secret, sessionToken, params) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + +// validWebIdentityToken is a structurally valid (but unverifiable — no +// registered provider will ever match its issuer) JWT carrying every claim +// AWS requires (including iat — its absence would itself be a rejection +// reason, see VerifyWebIdentityRequiredClaims), sufficient for exercising +// every AssumeRoleWithWebIdentity validation step that runs before the +// network call to fetch a provider's signing keys. +const validWebIdentityToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJpc3MiOiJodHRwczovL3VucmVnaXN0ZXJlZC5leGFtcGxlLmNvbSIsImF1ZCI6ImNsaWVudDEiLCJzdWIiOiJ1c2VyMSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ." + + "c2lnbmF0dXJl" + +func TestIAMApiControllerAssumeRoleWithWebIdentityRequiresNoAuth(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A completely unsigned request (no Authorization header, no query + // auth params at all) must still reach business logic rather than + // being rejected for missing credentials — the entire point of this + // action is that no AWS credentials are required. + resp := doSTSAction(t, server, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must not be null") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityValidationErrors(t *testing.T) { + server := newIAMControllerTestServer(t) + const roleArn = "arn:aws:iam::000000000000:role/does-not-exist" + + tests := []struct { + name string + params url.Values + wantStatus int + wantErrType string + wantCode string + wantMessage string + }{ + { + name: "missing RoleSessionName", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleSessionName' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid RoleSessionName characters", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"bad session!!"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value 'bad session!!' at 'roleSessionName' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w+=,.@-]*", + }, + { + name: "missing WebIdentityToken", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'webIdentityToken' failed to satisfy constraint: Member must not be null", + }, + { + name: "malformed (non-JWT) token", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {"not-a-real-jwt-token"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "InvalidIdentityToken", + wantMessage: "The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token)", + }, + { + name: "duration too low", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"100"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value '100' at 'durationSeconds' failed to satisfy constraint: Member must have value greater than or equal to 900", + }, + { + name: "duration too high", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"50000"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value '50000' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200", + }, + { + name: "RoleArn too short", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"short"}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must have length greater than or equal to 20", + }, + { + name: "RoleArn too long", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn + strings.Repeat("a", 2048)}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'roleArn' failed to satisfy constraint: Member must have length less than or equal to 2048", + }, + { + name: "WebIdentityToken too short", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}, "WebIdentityToken": {"ab"}}, + wantStatus: http.StatusBadRequest, + wantErrType: "Sender", + wantCode: "ValidationError", + wantMessage: "1 validation error detected: Value at 'webIdentityToken' failed to satisfy constraint: Member must have length greater than or equal to 4", + }, + { + name: "nonexistent role", + params: url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {roleArn}, "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}}, + wantStatus: http.StatusForbidden, + wantErrType: "Sender", + wantCode: "AccessDenied", + wantMessage: "Not authorized to perform sts:AssumeRoleWithWebIdentity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := doSTSAction(t, server, tt.params) + requireSTSError(t, resp, tt.wantStatus, tt.wantErrType, tt.wantCode, tt.wantMessage) + }) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityErrorsUseSTSNamespace(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + body := readBody(t, resp) + if !strings.Contains(body, `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`) { + t.Fatalf("error response missing STS namespace: %s", body) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityDurationExceedsRoleMax(t *testing.T) { + server := newIAMControllerTestServer(t) + + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/my-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "DurationSeconds": {"7200"}, // role's default MaxSessionDuration is 3600 + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "The requested DurationSeconds exceeds the MaxSessionDuration set for this role.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityNoMatchingPrincipal(t *testing.T) { + server := newIAMControllerTestServer(t) + + // The trust policy's Federated principal never corresponds to a real, + // registered OIDC provider (it was never created) — this is reported + // identically to a nonexistent role, never confirming or denying + // whether the role itself exists. + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"dangling-trust-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/dangling-trust-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRejectsUnsupportedParams(t *testing.T) { + tests := []struct { + name string + wantParam string // the parameter name UnsupportedParameter's message names; defaults to name if empty + params url.Values + }{ + { + name: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns member 2", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.2.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns member 10", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.10.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns with an index gap (member 3 only, no 1 or 2)", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.3.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }, + }, + { + name: "PolicyArns empty-but-present value", + wantParam: "PolicyArns", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {""}, + }, + }, + { + name: "ProviderId", + params: url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "ProviderId": {"www.amazon.com"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, tt.params) + wantParam := tt.wantParam + if wantParam == "" { + wantParam = tt.name + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidInput", wantParam+" is not supported by this implementation.") + }) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRejectsPolicyArnsInQueryString(t *testing.T) { + server := newIAMControllerTestServer(t) + + params := url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "Version": {stsAPIVersion}, + "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + } + req := httptest.NewRequest(http.MethodGet, "http://example.com/?"+params.Encode(), nil) + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidInput", "PolicyArns is not supported by this implementation.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityRoleArnPathMismatch(t *testing.T) { + server := newIAMControllerTestServer(t) + + createResp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"path-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`}, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createResp.StatusCode, readBody(t, createResp)) + } + + // "path-role" was created with the default "/" path, so its real Arn is + // arn:...:role/path-role — not arn:...:role/some/path/path-role. Only + // the role name matched; the full ARN (path included) must not. + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/some/path/path-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {validWebIdentityToken}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +// webIdentityTokenWithClaims builds an unverified (but structurally valid) +// JWT carrying claims — sufficient for every AssumeRoleWithWebIdentity trust +// evaluation test below, since none of them ever reach real signature +// verification (a trust-policy mismatch, audience mismatch, or condition +// failure is always detected first). +func webIdentityTokenWithClaims(t *testing.T, claims map[string]any) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".c2lnbmF0dXJl" +} + +// createTestOIDCProviderForTrust creates a real, registered OIDC provider at +// url (scheme included) with clientIDs, returning its ARN for use as a role +// trust policy's Federated principal. +func createTestOIDCProviderForTrust(t *testing.T, server *IAMApiServer, url_, clientID string) string { + t.Helper() + params := url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {url_}, + "ThumbprintList.member.1": {"6938fd4d98bab03faadb97b34396831e3780aea1"}, + } + if clientID != "" { + params.Set("ClientIDList.member.1", clientID) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out iamtypes.CreateOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, resp), &out) + return out.Result.OpenIDConnectProviderArn +} + +func createTestRoleForTrust(t *testing.T, server *IAMApiServer, roleName, trustPolicy string) { + t.Helper() + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {roleName}, + "AssumeRolePolicyDocument": {trustPolicy}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityNoIssuerMatch(t *testing.T) { + server := newIAMControllerTestServer(t) + + // The trust policy's Federated principal resolves to a real, registered + // provider — but that provider's own Url doesn't match the token's iss + // claim. Unlike NoPrincipal (no such provider at all), this is reported + // as InvalidIdentityToken, confirming the role's existence is no longer + // masked once its trust policy references at least one real provider. + providerArn := createTestOIDCProviderForTrust(t, server, "https://registered.example.com", "client1") + createTestRoleForTrust(t, server, "no-issuer-match-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://different-issuer.example.com", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/no-issuer-match-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityConditionFailed(t *testing.T) { + server := newIAMControllerTestServer(t) + + providerArn := createTestOIDCProviderForTrust(t, server, "https://cond.example.com", "client1") + createTestRoleForTrust(t, server, "condition-failed-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"cond.example.com:sub":"expected-user"}}}]}`) + + // Provider matches (iss == cond.example.com) but sub doesn't satisfy the + // trust statement's Condition block. + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://cond.example.com", "aud": "client1", "sub": "someone-else", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/condition-failed-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityExplicitDeny(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A broad Allow is present, but a Deny statement matching the same + // provider/action/condition takes precedence — reported as AccessDenied, + // identically to a role that doesn't authorize the caller at all, never + // as InvalidIdentityToken (Deny is a distinct outcome from a mismatched + // condition on an Allow). + providerArn := createTestOIDCProviderForTrust(t, server, "https://deny.example.com", "client1") + createTestRoleForTrust(t, server, "explicit-deny-role", + `{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"},`+ + `{"Effect":"Deny","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"deny.example.com:sub":"blocked-user"}}}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://deny.example.com", "aud": "client1", "sub": "blocked-user", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/explicit-deny-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityAudienceNotInClientIDList(t *testing.T) { + server := newIAMControllerTestServer(t) + + // Trust evaluation passes (the provider matches iss, no Condition to + // fail), but the token's audience isn't among the provider's own + // ClientIDList — a distinct check, made only after trust evaluation + // succeeds, that still reports the same InvalidIdentityToken as a + // Condition failure would. + providerArn := createTestOIDCProviderForTrust(t, server, "https://aud-mismatch.example.com", "allowed-client") + createTestRoleForTrust(t, server, "audience-mismatch-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://aud-mismatch.example.com", "aud": "not-the-allowed-client", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/audience-mismatch-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityEmptyClientIDList(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A provider with no registered client IDs at all can never satisfy the + // audience check, no matter what the token's aud claim is. + providerArn := createTestOIDCProviderForTrust(t, server, "https://no-clients.example.com", "") + createTestRoleForTrust(t, server, "empty-client-list-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://no-clients.example.com", "aud": "anything", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/empty-client-list-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.") +} + +func TestIAMApiControllerAssumeRoleWithWebIdentityMultiplePrincipalsInArray(t *testing.T) { + server := newIAMControllerTestServer(t) + + // A Federated principal can be a JSON array of ARNs, not just a bare + // string — the token's issuer only needs to match one of them. Both + // providers use loopback IP hosts (rather than DNS names) so that once + // the flow reaches signature verification, the SSRF guard rejects the + // dial immediately and deterministically instead of the test depending + // on (and being slowed or flaked by) real DNS resolution. + otherProviderArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.2", "client1") + matchingProviderArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.3", "client1") + createTestRoleForTrust(t, server, "multi-principal-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":["`+otherProviderArn+`","`+matchingProviderArn+`"]},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://127.0.0.3", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/multi-principal-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + // Passes trust evaluation and the audience check; fails only at the + // network-dependent signature verification step (see the IDP + // communication error test below for that path exercised + // deterministically) — here it's enough to confirm it gets that far + // rather than being rejected as AccessDenied/InvalidIdentityToken. + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements") +} + +// TestIAMApiControllerAssumeRoleWithWebIdentityIDPCommunicationError confirms +// the network-dependent signature-verification step is wired all the way +// through the real HTTP action handler: a provider Url that's an IP literal +// in a private/loopback range is rejected by VerifyWebIdentitySignature's +// mandatory SSRF guard before any real network attempt, deterministically +// and without requiring outbound network access from the test environment — +// the same technique +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// uses for CreateOpenIDConnectProvider's auto-fetch path. +func TestIAMApiControllerAssumeRoleWithWebIdentityIDPCommunicationError(t *testing.T) { + server := newIAMControllerTestServer(t) + + providerArn := createTestOIDCProviderForTrust(t, server, "https://127.0.0.1", "client1") + createTestRoleForTrust(t, server, "idp-comm-error-role", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"`+providerArn+`"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`) + + token := webIdentityTokenWithClaims(t, map[string]any{ + "iss": "https://127.0.0.1", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + resp := doSTSAction(t, server, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, + "RoleArn": {"arn:aws:iam::000000000000:role/idp-comm-error-role"}, + "RoleSessionName": {"session1"}, + "WebIdentityToken": {token}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken", + "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements") +} + +func TestIAMApiControllerGetCallerIdentityRoot(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doSignedSTSAction(t, server, testRoot.Access, testRoot.Secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + body := readBody(t, resp) + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, body, &out) + if out.Result.Arn != "arn:aws:iam::000000000000:root" { + t.Fatalf("GetCallerIdentity root Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "000000000000" || out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity root UserId/Account = %q/%q", out.Result.UserId, out.Result.Account) + } + if !strings.Contains(body, `xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`) { + t.Fatalf("success response missing STS namespace: %s", body) + } +} + +func TestIAMApiControllerGetCallerIdentityNoAuth(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSTSAction(t, server, url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "MissingAuthenticationToken", "Request is missing Authentication Token") +} + +func TestIAMApiControllerGetCallerIdentityWrongVersionIsInvalidAction(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doSignedSTSAction(t, server, testRoot.Access, testRoot.Secret, "", url.Values{ + "Action": {"GetCallerIdentity"}, + "Version": {iamAPIVersion}, + }) + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidAction", "Could not find operation GetCallerIdentity for version "+iamAPIVersion) +} + +func TestIAMApiControllerGetCallerIdentityWithSession(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTSESSION1234567", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/my-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "AROAtESTROLE123456789:my-session" { + t.Fatalf("GetCallerIdentity session UserId = %q", out.Result.UserId) + } + if out.Result.Account != "000000000000" { + t.Fatalf("GetCallerIdentity session Account = %q", out.Result.Account) + } +} + +func TestIAMApiControllerGetCallerIdentityWithSessionWrongToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTSESSION7654321", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Right access key and secret, but a security token that doesn't match + // the stored session must still be rejected. + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, "wrong-token", + url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +func TestIAMApiControllerGetCallerIdentityWithExpiredSession(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTEXPIRED1234567", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(-time.Minute), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +// TestIAMApiControllerGetCallerIdentityWithSessionAfterRoleDeleted confirms +// resolveSessionIdentity's documented behavior: a signature-valid, unexpired +// session still authenticates and answers GetCallerIdentity even after its +// assumed role has since been deleted — real STS credentials are +// self-contained and don't re-check role existence on every call. +func TestIAMApiControllerGetCallerIdentityWithSessionAfterRoleDeleted(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTDELETEDROLE123", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/ephemeral-role", + RoleName: "ephemeral-role", + RoleID: "AROAtESTROLE987654321", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + // Note: no CreateRole call — the role this session names never existed + // (or, equivalently, was deleted after the session was minted). + + resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + url.Values{"Action": {"GetCallerIdentity"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/ephemeral-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } + if out.Result.UserId != "AROAtESTROLE987654321:my-session" { + t.Fatalf("GetCallerIdentity session UserId = %q", out.Result.UserId) + } +} + +// TestIAMApiControllerGetCallerIdentityIncorrectServiceScope confirms the +// shared sigv4 auth pipeline reports the STS-specific service name ("sts", +// not "iam") when GetCallerIdentity is signed with a Credential scoped to +// the wrong service — the same generic mapIAMSigV4Error path +// authentication_test.go already exercises for "iam"-scoped actions, +// parameterized here by the "sts" service GetCallerIdentity actually signs +// for. +func TestIAMApiControllerGetCallerIdentityIncorrectServiceScope(t *testing.T) { + server := newIAMControllerTestServer(t) + + req := signedSTSRequest(t, testRoot.Access, testRoot.Secret, "", url.Values{"Action": {"GetCallerIdentity"}}) + authHdr := req.Header.Get("Authorization") + authHdr = strings.Replace(authHdr, "/sts/aws4_request", "/iam/aws4_request", 1) + req.Header.Set("Authorization", authHdr) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusBadRequest, "Sender", "SignatureDoesNotMatch", "Credential should be scoped to correct service: 'sts'.") +} + +// querySignedSTSRequest builds a genuinely presigned (query-string SigV4) +// GET request scoped to "sts" (matching a real STS SDK client's presigned +// URL), signed with the given credentials. When sessionToken is non-empty, +// the real v4 signer adds X-Amz-Security-Token to the query string itself +// — the same way AWS's own SDKs presign a request for temporary +// credentials (confirmed live against real AWS: such a request, submitted +// as a plain HTTP GET with no Authorization header, succeeds). +func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target string) *http.Request { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, target, nil) + hash := sha256.Sum256(nil) + payloadHash := hex.EncodeToString(hash[:]) + + creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken} + signer := awsv4.NewSigner() + signedURL, _, err := signer.PresignHTTP(context.Background(), creds, req, payloadHash, "sts", iammiddleware.SigningRegion, time.Now().UTC()) + if err != nil { + t.Fatalf("presign sts request: %v", err) + } + + return httptest.NewRequest(http.MethodGet, signedURL, nil) +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken confirms a +// temporary (ASIA…) session CAN authenticate via query-string (presigned +// URL) auth when X-Amz-Security-Token matches the session — confirmed live +// against real AWS (a genuine sts.PresignClient-generated presigned +// GetCallerIdentity request, signed with real ASIA… credentials and +// submitted as a plain HTTP GET, returns 200). +func TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTQUERYAUTH12345", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + req := querySignedSTSRequest(t, session.AccessKeyId, session.SecretAccessKey, session.SessionToken, + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out iamtypes.GetCallerIdentityResponse + unmarshalXML(t, readBody(t, resp), &out) + if out.Result.Arn != "arn:aws:sts::000000000000:assumed-role/my-role/my-session" { + t.Fatalf("GetCallerIdentity session Arn = %q", out.Result.Arn) + } +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthWithMismatchedSessionToken +// confirms a session presented via query auth still must carry the correct +// X-Amz-Security-Token — an unrelated token doesn't let a stolen/guessed +// temporary access key and secret through. +func TestIAMApiControllerGetCallerIdentityQueryAuthWithMismatchedSessionToken(t *testing.T) { + server := newIAMControllerTestServer(t) + + now := time.Now().UTC() + session := iamtypes.Session{ + AccessKeyId: "ASIAtESTQUERYAUTH99999", + SecretAccessKey: "sessionsecret", + SessionToken: "sessiontoken", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAtESTROLE123456789", + RoleSessionName: "my-session", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := server.store.CreateSession(context.Background(), session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + req := querySignedSTSRequest(t, session.AccessKeyId, session.SecretAccessKey, "wrong-token", + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} + +// TestIAMApiControllerGetCallerIdentityQueryAuthLongTermCredentialWithTokenRejected +// confirms a long-term (AKIA…) user credential carrying a security token in +// the query string is still always rejected outright — that combination +// can never be legitimate, since a long-term secret never has a +// corresponding session token to match. +func TestIAMApiControllerGetCallerIdentityQueryAuthLongTermCredentialWithTokenRejected(t *testing.T) { + server := newIAMControllerTestServer(t) + accessKeyID, secret := createTestUserWithAccessKey(t, server, "heidi", "") + + req := querySignedSTSRequest(t, accessKeyID, secret, "", + "http://example.com/?Action=GetCallerIdentity&Version="+stsAPIVersion) + q := req.URL.Query() + q.Set(sigv4auth.QuerySecurityToken, "bogus-token") + req.URL.RawQuery = q.Encode() + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + requireSTSError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.") +} diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 14d24010..533b6bd3 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -17,6 +17,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/xml" + "errors" "fmt" "net/http" "strings" @@ -26,6 +27,7 @@ import ( const ( Namespace = "https://iam.amazonaws.com/doc/2010-05-08/" AWSFaultNamespace = "http://webservices.amazon.com/AWSFault/2005-15-09" + STSNamespace = "https://sts.amazonaws.com/doc/2011-06-15/" ) type ErrorType string @@ -253,6 +255,24 @@ func GetAPIError(code ErrorCode) Error { return errorCodeResponse[ErrInternalFailure] } +// WithNamespace returns err with its XML namespace overridden to namespace, +// for errors that must render under a different service's namespace than +// the one they were originally constructed with (STS actions sharing this +// gateway's IAM endpoint being the only current case). It never overrides +// an already-explicit namespace (e.g. InvalidAction's AWSFaultNamespace, +// used for a request whose Version doesn't even resolve to a known +// action. +func WithNamespace(err error, namespace string) error { + var apiErr Error + if errors.As(err, &apiErr) { + if apiErr.XMLNamespace == "" { + apiErr.XMLNamespace = namespace + } + return apiErr + } + return err +} + func InvalidAction(action, version string) Error { err := newSenderError("InvalidAction", fmt.Sprintf("Could not find operation %s for version %s", action, version), http.StatusBadRequest) err.XMLNamespace = AWSFaultNamespace @@ -506,6 +526,71 @@ func OpenIdIdpCommunicationError(url string) Error { return newSenderError("OpenIdIdpCommunicationError", fmt.Sprintf("Could not connect to %s", url), http.StatusBadRequest) } +func IncorrectServiceScope(expectedService string) Error { + return newSenderError("SignatureDoesNotMatch", fmt.Sprintf("Credential should be scoped to correct service: '%s'.", expectedService), http.StatusBadRequest) +} + +func InvalidIdentityTokenMalformed() Error { + return newSenderError("InvalidIdentityToken", "The ID Token provided is not a valid JWT. (You may see this error if you sent an Access Token)", http.StatusBadRequest) +} + +func InvalidIdentityTokenClaims() Error { + return newSenderError("InvalidIdentityToken", "The web identity token provided could not be validated. See the AssumeRoleWithWebIdentity documentation for requirements.", http.StatusBadRequest) +} + +func InvalidIdentityTokenMultipleAudiences() Error { + return newSenderError("InvalidIdentityToken", "Token audience contains more than one audience while authorized party is not present", http.StatusBadRequest) +} + +func InvalidIdentityTokenIDPCommunicationError() Error { + return newSenderError("InvalidIdentityToken", "Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements", http.StatusBadRequest) +} + +func ExpiredWebIdentityToken(now, exp int64) Error { + return newSenderError("ExpiredTokenException", fmt.Sprintf("Token expired: current date/time %d must be before the expiration date/time %d", now, exp), http.StatusBadRequest) +} + +func UnsupportedParameter(parameter string) Error { + return newSenderError("InvalidInput", fmt.Sprintf("%s is not supported by this implementation.", parameter), http.StatusBadRequest) +} + +func InvalidIdentityTokenMissingClaim(claim string) Error { + return newSenderError("InvalidIdentityToken", fmt.Sprintf("Missing a required claim: %s.", claim), http.StatusBadRequest) +} + +func AccessDeniedAssumeRoleWithWebIdentity() Error { + return newSenderError("AccessDenied", "Not authorized to perform sts:AssumeRoleWithWebIdentity", http.StatusForbidden) +} + +func InvalidRoleSessionName(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'roleSessionName' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w+=,.@-]*", value)) +} + +func DurationSecondsTooLow(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'durationSeconds' failed to satisfy constraint: Member must have value greater than or equal to 900", value)) +} + +func DurationSecondsTooHigh(value string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200", value)) +} + +func DurationExceedsMaxSessionDuration() Error { + return ValidationError("The requested DurationSeconds exceeds the MaxSessionDuration set for this role.") +} + +func AccessDeniedIAMAction(callerArn, action string) Error { + return newSenderError("AccessDenied", fmt.Sprintf( + "User: %s is not authorized to perform: %s because no identity-based policy allows the %s action", + callerArn, action, action, + ), http.StatusForbidden) +} + +func ConcurrentModification() Error { + return newSenderError("ConcurrentModificationException", + "The request was rejected because multiple requests to change this object were submitted simultaneously. Wait a few minutes and submit your request again.", + http.StatusConflict) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iammiddleware/auth.go b/iamapi/internal/iammiddleware/auth.go index aaf09fd0..c79284ee 100644 --- a/iamapi/internal/iammiddleware/auth.go +++ b/iamapi/internal/iammiddleware/auth.go @@ -14,12 +14,17 @@ package iammiddleware import ( + "context" "errors" "strconv" "time" "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/httpctx" "github.com/versity/versitygw/internal/sigv4auth" ) @@ -28,61 +33,245 @@ const ( timeExpiration = 15 * time.Minute ) -var requiredSignedHeaders = []string{"host"} +// requiredSignedHeaders is the header-auth SignedHeaders policy for a +// permanent (root or AKIA…) credential. requiredTempSignedHeaders is the +// counterpart for a temporary (ASIA…) session credential: it additionally +// requires the session-token header be signed whenever it's present, +// matching standard AWS SDK behavior — defense in depth on top of the +// independent, access-key-bound SessionToken equality check in +// resolveSessionIdentity, so the header can't be silently dropped from the +// canonical request and left unbound to the signature. +// +// This only applies to header auth. Query-string (presigned) auth carries +// the token as a query parameter instead, which createPresignedHTTPRequestFromCtx +// already includes in the signed canonical query string regardless of +// SignedHeaders, so requiredSignedHeaders (unconditionally "host") is used +// for both root/permanent and session query-auth requests. +var ( + requiredSignedHeaders = []string{"host"} + requiredTempSignedHeaders = []string{"host", sigv4auth.HeaderSecurityToken} +) + +// requiredHeaderAuthSignedHeaders returns the SignedHeaders policy +// checkSignature enforces for header-based auth, based on whether accessKey +// is a temporary (ASIA…) session credential. +func requiredHeaderAuthSignedHeaders(accessKey string) []string { + if iamutil.IsTempAccessKeyID(accessKey) { + return requiredTempSignedHeaders + } + return requiredSignedHeaders +} type RootCredentials struct { Access string Secret string } -func VerifyIAMAuth(root *RootCredentials) fiber.Handler { +// IdentityStore resolves an access key id to the session or long-term user +// that owns it, and resolves named resources for policy evaluation. +// storage.Storer satisfies this directly. +type IdentityStore interface { + GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) + GetUser(ctx context.Context, username string) (*types.User, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error +} + +// VerifyIAMAuth authenticates a request against service (sigv4auth.ServiceIAM +// or sigv4auth.ServiceSTS). +// +// Three kinds of credential are accepted: the configured root user, a +// long-term (AKIA…) IAM user access key, or a temporary (ASIA…) session +// minted by AssumeRoleWithWebIdentity. Whichever it is, the resolved +// identity (and, for a user/session, its policy documents) is stored via +// httpctx.ContextKeyCallerIdentity for the policy middleware and controllers +// to read back. Root bypasses the policy middleware entirely +func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) fiber.Handler { return func(ctx fiber.Ctx) error { - authData, tdate, queryAuth, err := parseIAMAuth(ctx) + authData, tdate, queryAuth, err := parseIAMAuth(ctx, service) if err != nil { return err } - if authData.Access != root.Access { + // A security token in the query string is only ever legitimate + // alongside a temporary (ASIA…) access key — reject it outright for + // root or any long-term (AKIA…) credential before any signature + // work, the same way for both, rather than letting it fall through + // to a signature-mismatch error once a tampered/unsigned token + // param invalidates the canonical query string. + if queryAuth && !iamutil.IsTempAccessKeyID(authData.Access) && + ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) { return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) } - contentLength, err := parseContentLength(ctx.Get("Content-Length")) + if authData.Access == root.Access { + if err := checkSignature(ctx, authData, root.Secret, tdate, queryAuth, service); err != nil { + return err + } + httpctx.ContextKeyCallerIdentity.Set(ctx, types.Identity{IsRoot: true}) + return nil + } + + identity, secret, err := resolveIdentity(ctx, store, authData, queryAuth) if err != nil { return err } - payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw()) - if queryAuth { - _, err = sigv4auth.CheckQuerySignature(ctx, authData, root.Secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ - Service: sigv4auth.ServiceIAM, - RequiredSignedHeaders: requiredSignedHeaders, - }) - } else { - _, err = sigv4auth.CheckSignature(ctx, authData, root.Secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ - Service: sigv4auth.ServiceIAM, - RequiredSignedHeaders: requiredSignedHeaders, - }) - } - if err != nil { - return mapIAMSigV4Error(err) + if err := checkSignature(ctx, authData, secret, tdate, queryAuth, service); err != nil { + return err } + httpctx.ContextKeyCallerIdentity.Set(ctx, *identity) + if identity.User != nil { + recordAccessKeyUsage(ctx.Context(), store, authData.Access, service) + } return nil } } -func parseIAMAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) { +// recordAccessKeyUsage best-effort-updates a permanent access key's +// GetAccessKeyLastUsed metadata (service, region, and timestamp) after it +// successfully authenticates a request, matching real IAM's behavior. A +// failure is only logged, never returned, since this is purely +// informational metadata and a lost update under concurrent use is +// immaterial. Called synchronously: a Storer implementation for which this +// update is network-bound (e.g. Vault) is expected to make it non-blocking +// itself rather than adding that latency to every authenticated request +func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKeyID, service string) { + if err := store.RecordAccessKeyUsage(reqCtx, accessKeyID, service, SigningRegion, time.Now().UTC()); err != nil { + debuglogger.Logf("failed to record access key last-used metadata for %q: %v", accessKeyID, err) + } +} + +// resolveIdentity resolves authData.Access to a session or long-term user, +// by its AKIA…/ASIA… prefix, and returns the generic identity the rest of +// the request pipeline uses along with the secret VerifyIAMAuth checks the +// signature against. It does not itself verify the SigV4 signature — the +// caller does that next, so a stolen/guessed access key or session token +// alone is never sufficient. +// +// A temporary session can be used via query-string (presigned URL) +// authentication — real AWS accepts X-Amz-Security-Token as a query +// parameter for exactly this (confirmed live: a genuine presigned +// sts:GetCallerIdentity request signed with temporary/session credentials, +// carrying X-Amz-Security-Token in the query string, succeeds against real +// AWS). VerifyIAMAuth already rejects a security token paired with any +// non-temporary credential (root included) before this is ever reached. +func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { + if store == nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + if iamutil.IsTempAccessKeyID(authData.Access) { + return resolveSessionIdentity(ctx, store, authData, queryAuth) + } + return resolveUserIdentity(ctx, store, authData) +} + +func resolveSessionIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) { + session, err := store.GetSession(ctx.Context(), authData.Access) + if err != nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + token := ctx.Get(sigv4auth.HeaderSecurityToken) + if queryAuth { + token = ctx.Query(sigv4auth.QuerySecurityToken) + } + if token == "" || !sigv4auth.SecureCompare(token, session.SessionToken) { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + // A signature-valid, unexpired session still authenticates even if its + // role has since been deleted — real STS credentials are self-contained + // and don't re-check role existence on every call. What such a session + // can no longer do is get any IAM action past the policy middleware: + // with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies + // by default, same effective outcome as an explicit rejection here would + // have had for every pipeline except GetCallerIdentity, which needs + // none of this and must keep working regardless. + // + // The reloaded role must also still be the *same* role the session was + // originally minted against — RoleID and Arn, both captured in the + // session at AssumeRoleWithWebIdentity time, must match the freshly + // loaded role's own values. Without this check, deleting a role and + // recreating one of the same name (necessarily getting a new RoleID) + // would let every pre-existing session for the old role silently + // inherit whatever policies the new role happens to carry. + identity := &types.Identity{ + Session: session, + SessionPolicy: session.Policy, + } + if role, err := store.GetRole(ctx.Context(), session.RoleName); err == nil && + role.RoleID == session.RoleID && role.Arn == session.RoleArn { + identity.Role = role + identity.IdentityPolicies = role.Policies.Inline + } + return identity, session.SecretAccessKey, nil +} + +func resolveUserIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData) (*types.Identity, string, error) { + user, err := store.GetUserByAccessKeyID(ctx.Context(), authData.Access) + if err != nil { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + var keyEntry *types.AccessKeyEntry + for i := range user.AccessKeys { + if user.AccessKeys[i].AccessKeyId == authData.Access { + keyEntry = &user.AccessKeys[i] + break + } + } + if keyEntry == nil || keyEntry.Status != iamutil.AccessKeyStatusActive { + return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID) + } + + identity := &types.Identity{ + User: user, + IdentityPolicies: user.Policies.Inline, + } + return identity, keyEntry.SecretAccessKey, nil +} + +func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, tdate time.Time, queryAuth bool, service string) error { + contentLength, err := parseContentLength(ctx.Get("Content-Length")) + if err != nil { + return err + } + + payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw()) + if queryAuth { + _, err = sigv4auth.CheckQuerySignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + Service: service, + RequiredSignedHeaders: requiredSignedHeaders, + }) + } else { + _, err = sigv4auth.CheckSignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{ + Service: service, + RequiredSignedHeaders: requiredHeaderAuthSignedHeaders(authData.Access), + }) + } + if err != nil { + return mapIAMSigV4Error(err, service) + } + return nil +} + +func parseIAMAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { if sigv4auth.IsQueryAuth(ctx) { - return parseIAMQueryAuth(ctx) + return parseIAMQueryAuth(ctx, expectedService) } if sigv4auth.IsQueryAuthV2(ctx) { return sigv4auth.AuthData{}, time.Time{}, false, iamerr.GetAPIError(iamerr.ErrUnsupportedSignatureVersion) } - return parseIAMHeaderAuth(ctx) + return parseIAMHeaderAuth(ctx, expectedService) } -func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) { +func parseIAMHeaderAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { authData := sigv4auth.AuthData{} authorization := ctx.Get("Authorization") @@ -106,9 +295,9 @@ func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, err return authData, time.Time{}, false, err } - authData, err = sigv4auth.ParseAuthorization(authorization, sigv4auth.ServiceIAM) + authData, err = sigv4auth.ParseAuthorization(authorization, expectedService) if err != nil { - return authData, time.Time{}, false, mapIAMSigV4Error(err, authorization) + return authData, time.Time{}, false, mapIAMSigV4Error(err, expectedService, authorization) } if authData.Region != SigningRegion { @@ -121,17 +310,25 @@ func parseIAMHeaderAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, err return authData, tdate, false, nil } -func parseIAMQueryAuth(ctx fiber.Ctx) (sigv4auth.AuthData, time.Time, bool, error) { - if ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) { - return sigv4auth.AuthData{}, time.Time{}, true, mapIAMSigV4Error(&sigv4auth.QueryError{Kind: sigv4auth.ErrQuerySecurityToken}) - } - +// parseIAMQueryAuth parses SigV4 query-string (presigned URL) authentication +// parameters. Unlike S3 (see s3api/utils/presign-auth-reader.go), IAM/STS +// query-auth does not use X-Amz-Expires at all: confirmed live (niksis02 +// profile) against real IAM's ListUsers — a presigned request with +// X-Amz-Expires omitted, non-numeric ("abc"), negative ("-5"), or far +// beyond the 604800-second S3 maximum ("9999999") is accepted every time, +// while a request merely signed too long ago is rejected with +// SignatureDoesNotMatch ("Signature expired: ... is now earlier than ... +// (... - 15 min.)") — byte-for-byte the same message this codebase's own +// SignatureDoesNotMatchExpired already produces. So X-Amz-Expires is +// neither required nor validated here, and the same fixed ±timeExpiration +// freshness window header auth uses applies to query auth too. +func parseIAMQueryAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthData, time.Time, bool, error) { authData, details, err := sigv4auth.ParseQueryAuthorization(ctx, sigv4auth.QueryAuthOptions{ - Service: sigv4auth.ServiceIAM, + Service: expectedService, Region: SigningRegion, }) if err != nil { - return authData, time.Time{}, true, mapIAMSigV4Error(err) + return authData, time.Time{}, true, mapIAMSigV4Error(err, expectedService) } if err := ValidateDateAt(details.SigningTime, time.Now().UTC()); err != nil { return authData, time.Time{}, true, err @@ -165,7 +362,7 @@ func ValidateDateAt(date, now time.Time) error { return nil } -func mapIAMSigV4Error(err error, authorization ...string) error { +func mapIAMSigV4Error(err error, expectedService string, authorization ...string) error { var queryErr *sigv4auth.QueryError if errors.As(err, &queryErr) { return mapIAMQueryError(queryErr) @@ -177,7 +374,7 @@ func mapIAMSigV4Error(err error, authorization ...string) error { if len(authorization) > 0 { authHeader = authorization[0] } - return mapIAMParseError(parseErr, authHeader) + return mapIAMParseError(parseErr, expectedService, authHeader) } var headersErr *sigv4auth.HeadersNotSignedError @@ -222,7 +419,7 @@ func mapIAMQueryError(err *sigv4auth.QueryError) error { } } -func mapIAMParseError(err *sigv4auth.ParseError, authorization string) error { +func mapIAMParseError(err *sigv4auth.ParseError, expectedService, authorization string) error { if authorization == "" { authorization = err.Input } @@ -247,7 +444,7 @@ func mapIAMParseError(err *sigv4auth.ParseError, authorization string) error { case sigv4auth.ErrMalformedCredential: return iamerr.IncompleteSignatureMalformedCredential(err.Input) case sigv4auth.ErrIncorrectService: - return iamerr.GetAPIError(iamerr.ErrIncorrectService) + return iamerr.IncorrectServiceScope(expectedService) case sigv4auth.ErrIncorrectTerminal: return iamerr.GetAPIError(iamerr.ErrInvalidTerminal) case sigv4auth.ErrInvalidDateFormat: diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go new file mode 100644 index 00000000..855753fb --- /dev/null +++ b/iamapi/internal/iammiddleware/policy.go @@ -0,0 +1,403 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iammiddleware + +import ( + "strconv" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/policy" + "github.com/versity/versitygw/iamapi/types" + "github.com/versity/versitygw/internal/httpctx" +) + +// iamActionPrefix is the policy-action vendor prefix for every action this +// middleware evaluates. It's only ever wired into the "iam" service +// pipeline — GetCallerIdentity and AssumeRoleWithWebIdentity +// (the two "sts" actions sharing this endpoint) never reach it, matching +// real AWS where sts:GetCallerIdentity requires no identity-based policy +// grant at all and AssumeRoleWithWebIdentity has no identity yet to check. +const iamActionPrefix = "iam:" + +// VerifyIAMPolicy authorizes an IAM action against the caller identity +// VerifyIAMAuth already resolved and stored via +// httpctx.ContextKeyCallerIdentity. Root bypasses this entirely. +// A long-term user is authorized by its own inline policies. +// A session is authorized by its assumed role's inline policies, +// additionally filtered by its own session policy if one was supplied — the +// session policy can only narrow, never widen, what the role otherwise +// allows: Effective permissions = Role identity-based permissions ∩ Session +// policy permissions. +// +// Authorization is evaluated as a full request context — action, resource, +// and condition — rather than action alone: store resolves the actual +// target resource's ARN (for actions naming an existing user/role/OIDC +// provider) so a Resource-scoped statement only grants what it names, and +// requestConditionContext supplies the request's aws:SourceIp/aws:username/ +// aws:PrincipalArn/aws:CurrentTime/aws:EpochTime values for a statement's +// Condition block. +func VerifyIAMPolicy(store IdentityStore) fiber.Handler { + return func(ctx fiber.Ctx) error { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + if identity.IsRoot { + return nil + } + + action, _ := iamutil.RequestParam(ctx, "Action") + fullAction := iamActionPrefix + action + + resourceArn, resourceTags := resourceForAction(ctx, store, action) + reqCtx := policy.RequestContext{ + Action: fullAction, + Resource: resourceArn, + Condition: requestConditionContext(ctx, identity, action, resourceTags), + } + + if !authorizeRequest(identity, reqCtx) { + return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + } + + // A rename/path-move is a two-resource transition: AWS's UpdateUser + // docs require permission on both the source object (checked above, + // via UserName) and the target object the user is being moved to. + if action == "UpdateUser" { + if target := updateUserTargetResource(ctx, store); target != "" { + targetCtx := reqCtx + targetCtx.Resource = target + if !authorizeRequest(identity, targetCtx) { + return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction) + } + } + } + + return nil + } +} + +// authorizeRequest reports whether reqCtx is allowed by identity's own +// inline policies and, for a session with a session policy attached, the +// narrowing session policy as well. +func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) bool { + if !policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx) { + return false + } + if identity.Session != nil && identity.SessionPolicy != "" { + sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}} + if !policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx) { + return false + } + } + return true +} + +// resourceForAction resolves the ARN action targets and, when that ARN names +// an existing resource, the tags currently stored on it +// — matching AWS's resource-type classification for each IAM API: a List +// action (or any action this doesn't specifically recognize) has no +// resource-level permissions and always evaluates against "*"; an action +// creating a new user/role/OIDC provider evaluates against the +// about-to-be-created resource's ARN, built from the request's own +// Path/Name parameters exactly as the corresponding controller method +// builds it, with no tags (the resource doesn't exist yet — aws:RequestTag +// is the applicable key for a Create action, see addRequestTagContext); an +// action naming an existing user/role by name evaluates against that +// entity's real, currently-stored Arn and Tags (resolved via store, since a +// custom Path means the caller-supplied name alone doesn't determine the +// ARN); an OIDC provider action already carries the exact target ARN as a +// request parameter, and its Tags are resolved via a single store lookup +// alongside it. +// +// A lookup failure (unknown name, or the request simply omits it) resolves +// to ("", nil), which only a wildcard Resource statement matches — the +// request still reaches the controller afterward, which reports the +// specific NoSuchEntity/MissingValue error if authorization happens to pass +// on a wildcard grant, or AccessDenied first if it doesn't. +func resourceForAction(ctx fiber.Ctx, store IdentityStore, action string) (string, []types.Tag) { + switch action { + case "CreateUser": + return newUserResource(ctx), nil + case "GetUser": + return getUserResource(ctx, store) + case "DeleteUser", "UpdateUser", "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey", + "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies": + return existingUserResource(ctx, store) + case "GetAccessKeyLastUsed": + return accessKeyOwnerResource(ctx, store) + case "CreateRole": + return newRoleResource(ctx), nil + case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies": + return existingRoleResource(ctx, store) + case "CreateOpenIDConnectProvider": + return newOIDCProviderResource(ctx), nil + case "GetOpenIDConnectProvider", "DeleteOpenIDConnectProvider", "AddClientIDToOpenIDConnectProvider", + "RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint": + arn, _ := iamutil.RequestParam(ctx, "OpenIDConnectProviderArn") + if arn == "" { + return "", nil + } + provider, err := store.GetOIDCProvider(ctx.Context(), arn) + if err != nil { + return arn, nil + } + return arn, provider.Tags + default: + return "*", nil + } +} + +func newUserResource(ctx fiber.Ctx) string { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "*" + } + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + return iamutil.BuildUserArn(iamutil.DefaultAccountID, path, userName) +} + +// existingUserResource resolves UserName to its stored Arn and Tags. An +// empty UserName resolves to ("", nil), the same lookup-failure fallback +// used elsewhere — none of this group's actions actually accept an omitted +// UserName (the controller layer requires it), so this only guards against +// a malformed request reaching here. +func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "", nil + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// getUserResource resolves GetUser's target: the named user's stored Arn and +// Tags, or — when UserName is omitted, matching the controller's (and real +// IAM's) "look up the caller's own identity" behavior — the calling user's +// own Arn and Tags. A session (assumed role) has no self IAM user to +// resolve, so it falls back to ("", nil), the same lookup-failure fallback +// used elsewhere. +func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity) + if identity.User != nil { + return identity.User.Arn, identity.User.Tags + } + return "", nil + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// accessKeyOwnerResource resolves GetAccessKeyLastUsed's target: unlike the +// rest of this group, the request carries no UserName at all, only the +// AccessKeyId being queried, so the resource-level check is against the IAM +// user that owns that key, matching real IAM's resource-type classification +// for this action. +func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId") + if !ok || accessKeyID == "" { + return "", nil + } + user, err := store.GetUserByAccessKeyID(ctx.Context(), accessKeyID) + if err != nil { + return "", nil + } + return user.Arn, user.Tags +} + +// updateUserTargetResource resolves the destination ARN an UpdateUser +// request would relocate UserName to, so the caller for a rename/path-move +// can be required to hold permission on the target object as well as the +// source (matching the UpdateUser API's documented requirement). It returns +// "" when the request doesn't actually relocate the user (neither NewPath +// nor NewUserName supplied) or when the source user can't be resolved, the +// same fallback used elsewhere when a lookup fails. +func updateUserTargetResource(ctx fiber.Ctx, store IdentityStore) string { + newPath, _ := iamutil.RequestParam(ctx, "NewPath") + newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") + if newPath == "" && newUserName == "" { + return "" + } + userName, ok := iamutil.RequestParam(ctx, "UserName") + if !ok || userName == "" { + return "" + } + user, err := store.GetUser(ctx.Context(), userName) + if err != nil { + return "" + } + finalPath := user.Path + if newPath != "" { + finalPath = newPath + } + finalUserName := user.UserName + if newUserName != "" { + finalUserName = newUserName + } + return iamutil.BuildUserArn(iamutil.DefaultAccountID, finalPath, finalUserName) +} + +func newRoleResource(ctx fiber.Ctx) string { + roleName, ok := iamutil.RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + return "*" + } + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + return iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName) +} + +func existingRoleResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) { + roleName, ok := iamutil.RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + return "*", nil + } + role, err := store.GetRole(ctx.Context(), roleName) + if err != nil { + return "", nil + } + return role.Arn, role.Tags +} + +func newOIDCProviderResource(ctx fiber.Ctx) string { + rawURL, ok := iamutil.RequestParam(ctx, "Url") + if !ok || rawURL == "" { + return "*" + } + url, err := iamutil.ValidateOIDCProviderURL(rawURL) + if err != nil { + return "" + } + return iamutil.BuildOIDCProviderArn(iamutil.DefaultAccountID, url) +} + +// requestConditionContext builds the "aws:"-keyed context a +// statement's Condition block is evaluated against: aws:CurrentTime and +// aws:EpochTime (the request's evaluation time, always available - needed +// for Date/Numeric time-based conditions to be usable at all), aws:SourceIp +// (the caller's address), aws:SecureTransport (whether the connection is +// TLS - AWS documents this key as present on every request, not just TLS +// ones), and — for a non-root identity — aws:PrincipalArn, aws:PrincipalAccount +// (this gateway is single-account, so it's always DefaultAccountID), and +// aws:userid together with, for a long-term user only, aws:username (AWS +// sets both simultaneously for an IAM user principal; a session has no +// aws:username, only aws:userid in IAM's own ":" +// form). For the three actions that accept a Tags parameter at creation +// time, aws:RequestTag/ (one per supplied tag) and aws:TagKeys (every +// supplied key) are populated the same way the controller itself parses +// Tags, so a tag-scoped Condition is enforceable against the resource about +// to be created. +// +// resourceTags are the tags currently stored on the resource +// resourceForAction resolved, if any — populated as both iam:ResourceTag/ +// (IAM's own documented resource-tag key) and aws:ResourceTag/ (the +// generic cross-service key AWS also exposes for a tagged resource), so a +// Condition written against either form sees the resource's real tags +// instead of always evaluating as absent. aws:PrincipalTag/ is +// populated from the caller's own tags: the User's, for a long-term user, or +// the assumed Role's, for a session (AWS's own behavior when no session +// tags were supplied at AssumeRole time — this gateway has no session-tag +// parameter, so the role's tags are the session's tags for its whole +// lifetime). +func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action string, resourceTags []types.Tag) map[string][]string { + condCtx := map[string][]string{} + now := time.Now().UTC() + condCtx["aws:CurrentTime"] = []string{now.Format(time.RFC3339)} + condCtx["aws:EpochTime"] = []string{strconv.FormatInt(now.Unix(), 10)} + condCtx["aws:SecureTransport"] = []string{strconv.FormatBool(ctx.Secure())} + if ip := ctx.IP(); ip != "" { + condCtx["aws:SourceIp"] = []string{ip} + } + if arn := callerArn(identity); arn != "" { + condCtx["aws:PrincipalArn"] = []string{arn} + condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID} + } + switch { + case identity.User != nil: + condCtx["aws:username"] = []string{identity.User.UserName} + condCtx["aws:userid"] = []string{identity.User.UserID} + addPrincipalTagContext(condCtx, identity.User.Tags) + case identity.Session != nil: + condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName} + if identity.Role != nil { + addPrincipalTagContext(condCtx, identity.Role.Tags) + } + } + + for _, tag := range resourceTags { + condCtx["iam:ResourceTag/"+tag.Key] = []string{tag.Value} + condCtx["aws:ResourceTag/"+tag.Key] = []string{tag.Value} + } + + switch action { + case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider": + addRequestTagContext(condCtx, ctx) + } + + return condCtx +} + +// addPrincipalTagContext populates aws:PrincipalTag/ from tags, the +// calling principal's own tags. +func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) { + for _, tag := range tags { + condCtx["aws:PrincipalTag/"+tag.Key] = []string{tag.Value} + } +} + +// addRequestTagContext populates aws:RequestTag/ and aws:TagKeys from +// the request's Tags parameter, parsed the same way the controller parses it +// for the actual create call. A parse failure (e.g. a malformed tag) is left +// unpopulated rather than surfaced here — the controller performs the same +// parse independently and will reject the request with the specific +// tag-validation error afterward, so no create can succeed with tags that +// silently evaded a tag-scoped Condition. +func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { + tags, err := iamutil.ParseTags(ctx) + if err != nil || len(tags) == 0 { + return + } + keys := make([]string, 0, len(tags)) + for _, tag := range tags { + condCtx["aws:RequestTag/"+tag.Key] = []string{tag.Value} + keys = append(keys, tag.Key) + } + condCtx["aws:TagKeys"] = keys +} + +// callerArn identifies identity the way real IAM error messages do: the +// user's own Arn, or the assumed-role session Arn. +func callerArn(identity types.Identity) string { + if identity.Session != nil { + return iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, identity.Session.RoleName, identity.Session.RoleSessionName) + } + if identity.User != nil { + return identity.User.Arn + } + return "" +} diff --git a/iamapi/internal/iamutil/access_key.go b/iamapi/internal/iamutil/access_key.go index 70457c4b..a60320db 100644 --- a/iamapi/internal/iamutil/access_key.go +++ b/iamapi/internal/iamutil/access_key.go @@ -18,6 +18,7 @@ import ( "crypto/rand" "encoding/base64" "regexp" + "strings" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" @@ -32,6 +33,12 @@ const ( minAccessKeyIDLen = 16 maxAccessKeyIDLen = 128 secretAccessKeyBytes = 30 + + // tempAccessKeyIDPrefix marks temporary credentials minted by + // AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that + // distinguishes them from long-term AKIA… access keys. + tempAccessKeyIDPrefix = "ASIA" + sessionTokenBytes = 128 ) var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`) @@ -58,6 +65,39 @@ func GenerateSecretAccessKey() (string, error) { return base64.StdEncoding.EncodeToString(b), nil } +// GenerateTempAccessKeyID returns a new cryptographically random temporary +// access key id in the ASIA… format, for credentials minted by +// AssumeRoleWithWebIdentity. +func GenerateTempAccessKeyID() (string, error) { + id, err := generateAWSID(tempAccessKeyIDPrefix, accessKeyIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate temporary IAM access key id: %v", err) + return "", err + } + return id, nil +} + +// GenerateSessionToken returns a new cryptographically random opaque +// session token for temporary credentials. Unlike AWS's own STS, whose +// session token self-encodes the session (so any STS host can validate it +// without shared state), this gateway looks the token up in its own +// session store, so an opaque random value is sufficient. +func GenerateSessionToken() (string, error) { + b := make([]byte, sessionTokenBytes) + if _, err := rand.Read(b); err != nil { + debuglogger.Logf("failed to generate IAM session token: %v", err) + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// IsTempAccessKeyID reports whether accessKeyID has the ASIA… prefix used +// for temporary credentials minted by AssumeRoleWithWebIdentity, as opposed +// to a long-term AKIA… access key. +func IsTempAccessKeyID(accessKeyID string) bool { + return strings.HasPrefix(accessKeyID, tempAccessKeyIDPrefix) +} + // ValidateAccessKeyID checks that accessKeyID fits within the allowed length // range and character set. func ValidateAccessKeyID(accessKeyID string) error { diff --git a/iamapi/internal/iamutil/oidc_thumbprint.go b/iamapi/internal/iamutil/oidc_thumbprint.go index 11ff9881..ab8dacc2 100644 --- a/iamapi/internal/iamutil/oidc_thumbprint.go +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -32,11 +32,12 @@ import ( const oidcThumbprintFetchTimeout = 8 * time.Second // FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch -// behavior: it opens a raw TLS handshake (crypto/tls, not a full -// HTTP GET) to host:443, where host is derived from providerURL (a -// scheme-stripped OIDC provider Url), and returns the SHA-1 thumbprint of -// the last (top-most/intermediate CA) certificate in the peer's presented -// chain. +// behavior: it opens a TLS handshake (crypto/tls, not a full HTTP GET) to +// host:443, where host is derived from providerURL (a scheme-stripped OIDC +// provider Url), verifying the presented chain against the system trust +// store and the provider's own hostname like any normal TLS client, and +// returns the SHA-1 thumbprint of the last (top-most/intermediate CA) +// certificate in the peer's presented chain. // // SSRF hardening (mandatory): the hostname is resolved once via // net.DefaultResolver.LookupIP; if any resolved address is @@ -47,13 +48,21 @@ const oidcThumbprintFetchTimeout = 8 * time.Second // time, closing the DNS-rebinding TOCTOU gap) while presenting the original // hostname via tls.Config.ServerName for SNI/certificate purposes. // -// tls.Config.InsecureSkipVerify is deliberately set: this handshake exists -// solely to observe whatever certificate chain the peer presents — that is -// the entire point of AWS's thumbprint-pinning feature (trusting an -// operator-established fingerprint for IDPs whose certs may not pass -// standard verification). No application data is sent or received over -// this connection, so skipping chain verification does not expose any real -// traffic to a MITM. +// Verification is deliberately NOT skipped here: unlike a one-shot +// connection whose result is used and discarded, the certificate observed +// during this handshake is persisted as a long-lived trust anchor, compared +// against every future JWKS fetch for this provider. An unauthenticated +// handshake would let an active network/DNS attacker present any chain they +// control at enrollment time and have it pinned as trusted, then later +// present a matching leaf issued by that same chain — with attacker-chosen +// signing keys — to any subsequent (equally unauthenticated) JWKS fetch. A +// provider whose certificate doesn't chain to a system-trusted root (e.g. a +// private/self-hosted IdP on an internal CA) simply can't use auto-fetch: +// the caller gets an error and must supply ThumbprintList explicitly, having +// obtained the fingerprint through some independently verified channel — +// the same operational shape WithOIDCThumbprintAutoFetchDisabled already +// provides unconditionally, scoped here to just the providers that fail +// public verification. func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { host := hostFromOIDCUrl(providerURL) displayURL := "https://" + providerURL @@ -73,25 +82,38 @@ func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { } } - dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} - conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), "443")) + thumbprint, err := dialAndVerifyThumbprint(ctx, net.JoinHostPort(ips[0].String(), "443"), host, nil) if err != nil { - debuglogger.Logf("oidc thumbprint fetch: tls dial failed for %q (%s): %v", host, ips[0], err) + debuglogger.Logf("oidc thumbprint fetch: tls dial/verify failed for %q (%s): %v — supply ThumbprintList explicitly for providers that fail public CA verification", host, ips[0], err) return "", iamerr.OpenIdIdpCommunicationError(displayURL) } + debuglogger.Logf("oidc thumbprint fetch: verified %q via system trust store, computed thumbprint %s", displayURL, thumbprint) + return thumbprint, nil +} + +// dialAndVerifyThumbprint dials addr over TLS, presenting host via SNI and +// verifying the peer's certificate against roots (nil selects the host +// system's trust store, FetchThumbprint's real usage), then returns +// ThumbprintFromChain's result for the now-verified presented chain. Split +// out from FetchThumbprint so the verification behavior itself is +// unit-testable with an explicit root pool — the same rationale as +// ThumbprintFromChain's own split, and for the same reason: FetchThumbprint's +// SSRF guard must always reject loopback targets, so it can never itself be +// exercised against a same-process test server. +func dialAndVerifyThumbprint(ctx context.Context, addr, host string, roots *x509.CertPool) (string, error) { + dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, RootCAs: roots}} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return "", err + } defer conn.Close() tlsConn, ok := conn.(*tls.Conn) if !ok { - return "", iamerr.OpenIdIdpCommunicationError(displayURL) + return "", errors.New("iamutil: non-TLS connection") } - thumbprint, err := ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) - if err != nil { - debuglogger.Logf("oidc thumbprint fetch: %v", err) - return "", iamerr.OpenIdIdpCommunicationError(displayURL) - } - return thumbprint, nil + return ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) } // ThumbprintFromChain computes AWS's documented OIDC thumbprint: the SHA-1 diff --git a/iamapi/internal/iamutil/oidc_thumbprint_test.go b/iamapi/internal/iamutil/oidc_thumbprint_test.go index 39d65a9c..66fc72fd 100644 --- a/iamapi/internal/iamutil/oidc_thumbprint_test.go +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -18,6 +18,7 @@ import ( "context" "crypto/sha1" "crypto/tls" + "crypto/x509" "encoding/hex" "net" "net/http/httptest" @@ -69,11 +70,56 @@ func TestThumbprintFromChainEmptyChain(t *testing.T) { } } +// TestDialAndVerifyThumbprintRejectsUntrustedCert verifies that +// dialAndVerifyThumbprint rejects a certificate that doesn't chain to a +// trusted root, rather than trusting whatever the peer presents — trusting +// any presented chain is exactly what would let an active network/DNS +// attacker at enrollment time have their own chain pinned as the provider's +// permanent trust anchor. A self-signed test server's certificate, which +// chains to nothing any real trust store recognizes, must be rejected +// instead of silently hashed. +func TestDialAndVerifyThumbprintRejectsUntrustedCert(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + // roots=nil selects the host system's real trust store, the same as + // FetchThumbprint's actual usage - httptest's self-signed certificate + // must not verify against it. + if _, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", nil); err == nil { + t.Fatal("dialAndVerifyThumbprint: expected verification error for untrusted self-signed certificate, got nil") + } +} + +// TestDialAndVerifyThumbprintAcceptsVerifiedCert is the positive +// counterpart: once the peer's certificate does verify (here, against an +// explicit pool containing the test server's own certificate, standing in +// for a real public CA in FetchThumbprint's system-trust-store case), +// auto-fetch must still succeed and compute the same thumbprint +// TestThumbprintFromChain gets by hashing the chain directly - proving the +// stricter check rejects only genuinely untrusted chains, not every chain. +func TestDialAndVerifyThumbprintAcceptsVerifiedCert(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + + got, err := dialAndVerifyThumbprint(context.Background(), srv.Listener.Addr().String(), "example.com", roots) + if err != nil { + t.Fatalf("dialAndVerifyThumbprint: %v", err) + } + + sum := sha1.Sum(srv.Certificate().Raw) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Fatalf("dialAndVerifyThumbprint thumbprint = %q, want %q", got, want) + } +} + // TestFetchThumbprintSSRFGuard confirms FetchThumbprint refuses to dial -// loopback/private targets before any network attempt, matching the -// mandatory SSRF hardening design: 127.0.0.1 is exactly the kind of -// address a malicious CreateOpenIDConnectProvider caller could supply to -// probe the gateway's own local network. +// loopback/private targets before any network attempt: 127.0.0.1 is exactly +// the kind of address a malicious CreateOpenIDConnectProvider caller could +// supply to probe the gateway's own local network. func TestFetchThumbprintSSRFGuard(t *testing.T) { tests := []string{ "127.0.0.1", diff --git a/iamapi/internal/iamutil/request_test.go b/iamapi/internal/iamutil/request_test.go index cce8c688..538fc227 100644 --- a/iamapi/internal/iamutil/request_test.go +++ b/iamapi/internal/iamutil/request_test.go @@ -72,3 +72,54 @@ func TestMatchQueryOrFormArgs(t *testing.T) { }) } } + +func TestHasRequestParamPrefix(t *testing.T) { + tests := []struct { + name string + method string + target string + body string + contentType string + want bool + }{ + {name: "query, member 1", method: http.MethodGet, target: "/any?PolicyArns.member.1.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, member 10", method: http.MethodGet, target: "/any?PolicyArns.member.10.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, index gap (member 3 only)", method: http.MethodGet, target: "/any?PolicyArns.member.3.arn=arn:aws:iam::000000000000:policy/p", want: true}, + {name: "query, empty-but-present value", method: http.MethodGet, target: "/any?PolicyArns.member.1.arn=", want: true}, + {name: "form, member 2", method: http.MethodPost, target: "/any", body: "PolicyArns.member.2.arn=arn:aws:iam::000000000000:policy/p", contentType: fiber.MIMEApplicationForm, want: true}, + {name: "absent", method: http.MethodGet, target: "/any?Action=AssumeRoleWithWebIdentity", want: false}, + {name: "unrelated prefix untouched", method: http.MethodGet, target: "/any?PolicyArnsSomethingElse=x", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := fiber.New() + app.Add([]string{http.MethodGet, http.MethodPost}, "/*", func(ctx fiber.Ctx) error { + if HasRequestParamPrefix(ctx, "PolicyArns.member.") { + return ctx.SendString("found") + } + return ctx.SendString("absent") + }) + + req := httptest.NewRequest(tt.method, tt.target, bytes.NewBufferString(tt.body)) + if tt.contentType != "" { + req.Header.Set("Content-Type", tt.contentType) + } + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + want := "absent" + if tt.want { + want = "found" + } + if string(body) != want { + t.Fatalf("HasRequestParamPrefix result = %q, want %q", string(body), want) + } + }) + } +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 24a91189..e1c2d981 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -73,6 +73,27 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) { return "", false } +// HasRequestParamPrefix reports whether any query or form parameter key +// (regardless of its value, including empty) starts with prefix. Unlike +// RequestParam, which probes one exact name, this scans every key actually +// present — needed to reject an AWS Query-protocol indexed-list parameter +// (e.g. "PolicyArns.member.N.arn") for every N a caller might supply, +// instead of only a fixed index like ".1.", which a caller could bypass +// entirely by supplying a different index, a gap, or several members. +func HasRequestParamPrefix(ctx fiber.Ctx, prefix string) bool { + for key := range ctx.Request().URI().QueryArgs().All() { + if strings.HasPrefix(string(key), prefix) { + return true + } + } + for key := range ctx.Request().PostArgs().All() { + if strings.HasPrefix(string(key), prefix) { + return true + } + } + return false +} + // GetUserName resolves the UserName request parameter and validates it // against maxLen, returning missingErr if the parameter is absent or empty. // operation is included in the debug log on failure (e.g. "DeleteUser"). diff --git a/iamapi/internal/iamutil/webidentity.go b/iamapi/internal/iamutil/webidentity.go new file mode 100644 index 00000000..9be96f66 --- /dev/null +++ b/iamapi/internal/iamutil/webidentity.go @@ -0,0 +1,887 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" + "golang.org/x/sync/singleflight" +) + +const ( + MinRoleSessionNameLen = 2 + MaxRoleSessionNameLen = 64 + + MinWebIdentityTokenLen = 4 + MaxWebIdentityTokenLen = 20000 + + MinRoleArnLen = 20 + MaxRoleArnLen = 2048 + + MinDurationSeconds = 900 + MaxDurationSeconds = 43200 + DefaultDurationSeconds = 3600 + + // webIdentityExpLeeway is AWS's observed clock-skew allowance for a web + // identity token's exp claim: a token expired by less than this is + // still accepted. + webIdentityExpLeeway = 5 * time.Minute + + oidcFetchTimeout = 8 * time.Second + maxOIDCFetchBodyBytes = 1 << 20 // 1 MiB; well beyond any real discovery doc or JWKS. + + // maxJWKSKeysPerType is AWS's documented OIDC provider JWKS limit: at + // most 100 RSA and 100 EC keys. A JWKS response exceeding either bound + // is rejected outright rather than accepted into the cache and iterated + // over on every verification. + maxJWKSKeysPerType = 100 + + // jwksMinForcedRefreshInterval rate-limits how often a token with an + // unrecognized kid can force a JWKS refresh for the same issuer, on top + // of jwksCacheTTL's normal expiry. Without this, anyone who knows a + // trusted issuer/audience/role ARN could send unlimited tokens carrying + // unique, made-up kid values and force a fresh discovery-document-plus- + // JWKS fetch against the real IdP for every single one, before any + // signature or authentication check ever runs. + jwksMinForcedRefreshInterval = 30 * time.Second + + // maxOIDCFetchRedirects bounds how many redirects a discovery-document + // or JWKS fetch will follow. net/http's own default client stops after + // 10 redirects, but that default is implemented by its CheckRedirect + // func - replacing CheckRedirect (as ssrfSafeHTTPClient does, to add the + // https-only and SSRF checks) silently loses that cap entirely unless + // the replacement enforces its own. + maxOIDCFetchRedirects = 5 +) + +var roleSessionNamePattern = regexp.MustCompile(`^[\w+=,.@-]*$`) + +// ValidateRoleSessionName checks RoleSessionName against STS's length and +// charset constraints. +func ValidateRoleSessionName(name string) error { + if len(name) < MinRoleSessionNameLen { + debuglogger.Logf("RoleSessionName too short: %q", name) + return iamerr.ValueTooShort("roleSessionName", MinRoleSessionNameLen) + } + if len(name) > MaxRoleSessionNameLen { + debuglogger.Logf("RoleSessionName too long: %q", name) + return iamerr.ValueTooLong("roleSessionName", MaxRoleSessionNameLen) + } + if !roleSessionNamePattern.MatchString(name) { + debuglogger.Logf("invalid RoleSessionName characters: %q", name) + return iamerr.InvalidRoleSessionName(name) + } + return nil +} + +// ValidateWebIdentityTokenLength checks WebIdentityToken against STS's +// length constraints (content/structure is validated separately by +// ParseWebIdentityClaims). +func ValidateWebIdentityTokenLength(token string) error { + if len(token) < MinWebIdentityTokenLen { + debuglogger.Logf("WebIdentityToken too short: length=%d", len(token)) + return iamerr.ValueTooShort("webIdentityToken", MinWebIdentityTokenLen) + } + if len(token) > MaxWebIdentityTokenLen { + debuglogger.Logf("WebIdentityToken too long: length=%d", len(token)) + return iamerr.ValueTooLong("webIdentityToken", MaxWebIdentityTokenLen) + } + return nil +} + +// ValidateRoleArnLength checks RoleArn against STS's length constraints. +func ValidateRoleArnLength(arn string) error { + if len(arn) < MinRoleArnLen { + debuglogger.Logf("RoleArn too short: %q", arn) + return iamerr.ValueTooShort("roleArn", MinRoleArnLen) + } + if len(arn) > MaxRoleArnLen { + debuglogger.Logf("RoleArn too long: length=%d", len(arn)) + return iamerr.ValueTooLong("roleArn", MaxRoleArnLen) + } + return nil +} + +// ParseDurationSeconds parses AssumeRoleWithWebIdentity's optional +// DurationSeconds request parameter, returning DefaultDurationSeconds +// (always 1 hour, regardless of the role's own MaxSessionDuration) when +// absent. +func ParseDurationSeconds(ctx fiber.Ctx) (int32, error) { + raw, ok := RequestParam(ctx, "DurationSeconds") + if !ok || raw == "" { + return DefaultDurationSeconds, nil + } + + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + debuglogger.Logf("malformed DurationSeconds value %q", raw) + return 0, iamerr.MalformedInput() + } + if parsed < MinDurationSeconds { + debuglogger.Logf("DurationSeconds too low: %s", raw) + return 0, iamerr.DurationSecondsTooLow(raw) + } + if parsed > MaxDurationSeconds { + debuglogger.Logf("DurationSeconds too high: %s", raw) + return 0, iamerr.DurationSecondsTooHigh(raw) + } + + return int32(parsed), nil +} + +// RoleNameFromAssumeArn extracts the role name from a RoleArn of the shape +// arn:aws:iam:::role/, for an assumed-role account +// matching accountID. Any other shape (wrong account, wrong resource type, +// not even ARN-shaped) reports ok=false: AssumeRoleWithWebIdentity treats +// all such cases identically (AccessDenied), never distinguishing "no such +// role" from "malformed ARN" the way other IAM actions do, so no error +// value is returned here. +func RoleNameFromAssumeArn(arn, accountID string) (roleName string, ok bool) { + const prefix = "arn:aws:iam::" + if !strings.HasPrefix(arn, prefix) { + return "", false + } + rest := strings.TrimPrefix(arn, prefix) + + acct, rest, found := strings.Cut(rest, ":") + if !found || acct != accountID { + return "", false + } + + resourceType, resource, found := strings.Cut(rest, "/") + if !found || resourceType != "role" || resource == "" { + return "", false + } + + if idx := strings.LastIndex(resource, "/"); idx >= 0 { + resource = resource[idx+1:] + } + if resource == "" { + return "", false + } + return resource, true +} + +// ParseWebIdentityClaims parses tokenString as a JWT without verifying its +// signature, returning its claims. This is the first step of +// AssumeRoleWithWebIdentity validation: the token's iss claim must be read +// before it's known which OIDC provider (and therefore which signing keys) +// to verify against. +func ParseWebIdentityClaims(tokenString string) (jwt.MapClaims, error) { + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + debuglogger.Logf("web identity token is not a valid JWT: %v", err) + return nil, iamerr.InvalidIdentityTokenMalformed() + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, iamerr.InvalidIdentityTokenMalformed() + } + return claims, nil +} + +// WebIdentityIssuer returns claims' iss value, scheme-stripped to match the +// stored form of a registered OIDC provider's Url. +// +// Only an "https://" prefix is stripped — OIDC issuer identifiers are +// compared exactly, scheme included, and CreateOpenIDConnectProvider already +// requires every registered provider's Url to be https. An iss using any +// other scheme (or none at all) therefore can never legitimately equal a +// registered provider; returning it unstripped in that case (rather than +// also trimming a bare "http://") guarantees it stays distinguishable from a +// same-host https issuer instead of being silently treated as equivalent. +func WebIdentityIssuer(claims jwt.MapClaims) (string, bool) { + iss, ok := claims["iss"].(string) + if !ok || iss == "" { + return "", false + } + if stripped, ok := strings.CutPrefix(iss, "https://"); ok { + return stripped, true + } + return iss, true +} + +// WebIdentityAudience resolves a web identity token's "effective audience" +// (the value AWS maps to the :aud trust-policy condition key) +// along with its original aud claim value(s) (mapped to :oaud +// whenever azp overrides them). +// +// Whenever azp (authorized party) is present, it is always the effective +// audience — regardless of whether aud itself carries one value or many — +// and the original aud claim value(s) are additionally returned for the +// oaud mapping; this matters for Google hybrid clients, where aud names the +// backend project and azp names the actual OAuth client that requested the +// token. A multi-valued aud with no azp is rejected — per OpenID Connect +// Core, a multi-audience ID token must carry azp to disambiguate which +// audience the token was issued for, and AWS enforces this as a hard +// requirement rather than a recommendation. +func WebIdentityAudience(claims jwt.MapClaims) (audience string, original []string, err error) { + var auds []string + switch v := claims["aud"].(type) { + case string: + if v != "" { + auds = []string{v} + } + case []any: + for _, e := range v { + if s, ok := e.(string); ok && s != "" { + auds = append(auds, s) + } + } + } + + if len(auds) == 0 { + debuglogger.Logf("web identity token has no aud claim") + return "", nil, iamerr.InvalidIdentityTokenClaims() + } + + if azp, _ := claims["azp"].(string); azp != "" { + return azp, auds, nil + } + + if len(auds) > 1 { + debuglogger.Logf("web identity token has multiple audiences %v but no azp claim", auds) + return "", nil, iamerr.InvalidIdentityTokenMultipleAudiences() + } + return auds[0], nil, nil +} + +// wellKnownClaims are excluded from ExtractClaimContext: they're either +// handled specially (iss/aud/azp/sub) or aren't meaningful as trust-policy +// Condition context (exp/iat/nbf are timestamps, not strings). +var wellKnownClaims = map[string]bool{ + "iss": true, "aud": true, "azp": true, "sub": true, + "exp": true, "iat": true, "nbf": true, +} + +// ExtractClaimContext projects every other top-level scalar or +// scalar-array claim from a web identity token into a plain map, for +// trust-policy Condition keys beyond the well-known "aud"/"sub" (e.g. a +// custom "amr" or "groups" claim, or a Bool/Numeric/Date condition against a +// custom "admin"/"tier"/"level" claim). +func ExtractClaimContext(claims jwt.MapClaims) map[string][]string { + out := make(map[string][]string, len(claims)) + for name, value := range claims { + if wellKnownClaims[name] { + continue + } + switch v := value.(type) { + case []any: + var values []string + for _, e := range v { + if s, ok := claimScalarString(e); ok { + values = append(values, s) + } + } + if len(values) > 0 { + out[name] = values + } + default: + if s, ok := claimScalarString(v); ok { + out[name] = []string{s} + } + } + } + return out +} + +// claimScalarString converts a single decoded JWT claim value to its +// Condition-context string form. golang-jwt decodes every JSON number as +// float64 and every JSON bool as bool (standard encoding/json behavior for +// an interface{} target) - without this, a claim like "tier": 3 or "admin": +// true would never reach the Condition context at all (the key would always +// look "absent"), silently defeating a Bool/Numeric/Date condition guarding +// it. 'f', -1 gives the shortest round-tripping decimal form (3.0 -> "3", +// 4.5 -> "4.5"), matching how a policy author would hand-write the value. +func claimScalarString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + case bool: + return strconv.FormatBool(v), true + default: + return "", false + } +} + +// BuildAssumedRoleArn constructs the ARN a role's temporary session +// credentials are identified by. Unlike the role's own ARN +// (arn:aws:iam::...:role/...), an assumed session uses the sts service. +func BuildAssumedRoleArn(accountID, roleName, roleSessionName string) string { + return fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountID, roleName, roleSessionName) +} + +// PackedPolicySize reports the percentage of policy.MaxSessionPolicyBytes +// sessionPolicy consumes, or nil if no session Policy parameter was +// supplied at all — matching how AWS omits PackedPolicySize entirely in +// that case rather than reporting 0%. +func PackedPolicySize(sessionPolicy string) *int64 { + if sessionPolicy == "" { + return nil + } + pct := int64(len(sessionPolicy) * 100 / policy.MaxSessionPolicyBytes) + return &pct +} + +// VerifyWebIdentityExpiration checks claims' exp against now, allowing +// webIdentityExpLeeway of clock skew. +func VerifyWebIdentityExpiration(claims jwt.MapClaims, now time.Time) error { + expFloat, ok := claims["exp"].(float64) + if !ok { + debuglogger.Logf("web identity token has no exp claim") + return iamerr.InvalidIdentityTokenClaims() + } + exp := int64(expFloat) + if now.After(time.Unix(exp, 0).Add(webIdentityExpLeeway)) { + debuglogger.Logf("web identity token expired: now=%d exp=%d", now.Unix(), exp) + return iamerr.ExpiredWebIdentityToken(now.Unix(), exp) + } + return nil +} + +// VerifyWebIdentityRequiredClaims checks claims for AWS's other mandatory +// web identity token claims beyond exp (already checked separately by +// VerifyWebIdentityExpiration): iat and sub must both be present, and nbf +// (if present) must not be in the future beyond webIdentityExpLeeway of +// clock skew. Confirmed against real AWS (niksis02 profile): a token with +// exp but no iat, or with iat but no sub, is rejected with +// InvalidIdentityToken "Missing a required claim: ." — without +// this check, such a token would otherwise obtain credentials whenever the +// role's trust policy doesn't itself require sub via Condition. +func VerifyWebIdentityRequiredClaims(claims jwt.MapClaims, now time.Time) error { + if _, ok := claims["iat"].(float64); !ok { + debuglogger.Logf("web identity token has no iat claim") + return iamerr.InvalidIdentityTokenMissingClaim("iat") + } + if sub, ok := claims["sub"].(string); !ok || sub == "" { + debuglogger.Logf("web identity token has no sub claim") + return iamerr.InvalidIdentityTokenMissingClaim("sub") + } + if nbfFloat, ok := claims["nbf"].(float64); ok { + nbf := time.Unix(int64(nbfFloat), 0) + if now.Before(nbf.Add(-webIdentityExpLeeway)) { + debuglogger.Logf("web identity token not yet valid: now=%d nbf=%d", now.Unix(), int64(nbfFloat)) + return iamerr.InvalidIdentityTokenClaims() + } + } + return nil +} + +// VerifyWebIdentitySignature fetches issuerURL's OIDC discovery document +// and JWKS (from cache when a fresh-enough entry exists), then verifies +// tokenString's signature against the matching key. On success it returns +// the token's verified claims (exp/nbf/iat are not re-checked here — +// callers that need those checks perform them separately with AWS-matching +// messages and leeway). +// +// thumbprints is the OIDC provider's registered ThumbprintList, used as a +// pinned-certificate fallback when the JWKS endpoint's TLS certificate +// doesn't chain to a trusted root (self-signed/private-CA providers). +// +// If the cached key set doesn't contain the token's kid, the cache is +// bypassed for one forced refresh before giving up — the provider may have +// rotated its signing key since the cache entry was fetched. +func VerifyWebIdentitySignature(ctx context.Context, tokenString, issuerURL string, thumbprints []string) (jwt.MapClaims, error) { + keys, err := cachedJWKS(ctx, issuerURL, thumbprints) + if err != nil { + debuglogger.Logf("failed to fetch JWKS for web identity provider %q: %v", issuerURL, err) + return nil, iamerr.InvalidIdentityTokenIDPCommunicationError() + } + + claims, err := verifySignatureWithKeys(tokenString, keys) + if err != nil && errors.Is(err, errUnknownKID) { + keys, refreshErr := forceRefreshJWKSCache(ctx, issuerURL, thumbprints) + if refreshErr != nil { + debuglogger.Logf("failed to refresh JWKS for web identity provider %q: %v", issuerURL, refreshErr) + return nil, iamerr.InvalidIdentityTokenIDPCommunicationError() + } + claims, err = verifySignatureWithKeys(tokenString, keys) + } + if err != nil { + debuglogger.Logf("web identity token signature verification failed: %v", err) + return nil, iamerr.InvalidIdentityTokenClaims() + } + return claims, nil +} + +// errUnknownKID is keyFunc's error when a token's kid names no key in the +// set — the signal VerifyWebIdentitySignature uses to force one cache +// refresh (the provider may have rotated its signing key) before giving up. +var errUnknownKID = errors.New("no matching JWKS key for kid") + +// verifySignatureWithKeys is VerifyWebIdentitySignature's network-free core, +// split out so it can be exercised directly against an in-memory key set +// (the SSRF guard in fetchJWKS's dialer means it can never itself be +// exercised against a same-process test server — the same split +// FetchThumbprint/ThumbprintFromChain use). The returned error is the raw +// parse/verification failure (not yet converted to an iamerr), so callers +// can distinguish errUnknownKID from every other failure. +func verifySignatureWithKeys(tokenString string, keys *jwkSet) (jwt.MapClaims, error) { + parser := jwt.NewParser( + jwt.WithoutClaimsValidation(), + jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}), + ) + token, err := parser.Parse(tokenString, keys.keyFunc) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("web identity token failed signature verification") + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("web identity token claims are not a JSON object") + } + return claims, nil +} + +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwkSet struct { + Keys []jwk `json:"keys"` +} + +// keyFunc resolves a token's verification key by matching its header kid +// against the set. A set with exactly one key is used regardless of kid +// (or its absence) — a common pattern for single-key providers. +func (s *jwkSet) keyFunc(token *jwt.Token) (any, error) { + kid, _ := token.Header["kid"].(string) + + if len(s.Keys) == 1 && (kid == "" || s.Keys[0].Kid == kid || s.Keys[0].Kid == "") { + return s.Keys[0].publicKey() + } + for _, k := range s.Keys { + if k.Kid == kid { + return k.publicKey() + } + } + return nil, fmt.Errorf("%w: %q", errUnknownKID, kid) +} + +func (k jwk) publicKey() (any, error) { + switch k.Kty { + case "RSA": + nb, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, fmt.Errorf("decode RSA modulus: %w", err) + } + eb, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("decode RSA exponent: %w", err) + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nb), + E: int(new(big.Int).SetBytes(eb).Int64()), + }, nil + case "EC": + var curve elliptic.Curve + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported EC curve %q", k.Crv) + } + xb, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + return nil, fmt.Errorf("decode EC x: %w", err) + } + yb, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + return nil, fmt.Errorf("decode EC y: %w", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xb), + Y: new(big.Int).SetBytes(yb), + }, nil + default: + return nil, fmt.Errorf("unsupported JWK key type %q", k.Kty) + } +} + +type oidcDiscoveryDoc struct { + Issuer string `json:"issuer"` + JWKSUri string `json:"jwks_uri"` +} + +// validateDiscoveryIssuer reports an error unless doc's issuer exactly +// matches issuerURL's provider Url: both the OIDC discovery spec and +// AWS's own documentation require an exact match, not merely a document +// reachable from the provider's own URL — otherwise a provider could return, +// or be redirected/misdirected to, an entirely different issuer's metadata. +func validateDiscoveryIssuer(doc oidcDiscoveryDoc, issuerURL string) error { + want := "https://" + issuerURL + if doc.Issuer != want { + return fmt.Errorf("discovery document for %q has mismatched issuer %q", issuerURL, doc.Issuer) + } + return nil +} + +// jwksCacheTTL bounds how long a fetched key set is reused before +// VerifyWebIdentitySignature fetches it again, so that a burst of +// AssumeRoleWithWebIdentity calls for the same provider doesn't turn into a +// discovery-document-plus-JWKS fetch per call (latency, rate-limiting, and — +// since this fetch happens before the caller is authenticated — anonymous +// request amplification against the IdP). +const jwksCacheTTL = 5 * time.Minute + +type jwksCacheEntry struct { + keys *jwkSet + expiresAt time.Time + // lastForcedRefresh is when an unknown-kid lookup last bypassed + // expiresAt to force a fetch for this issuer, gating + // jwksMinForcedRefreshInterval (see forceRefreshJWKSCache). + lastForcedRefresh time.Time +} + +var ( + jwksCacheMu sync.Mutex + jwksCache = map[string]jwksCacheEntry{} + + // jwksFetchGroup coalesces concurrent fetches for the same issuerURL — + // from cache-expiry and forced unknown-kid refreshes alike — into a + // single outbound discovery-document-plus-JWKS request, so a burst of + // simultaneous AssumeRoleWithWebIdentity calls (e.g. many callers' + // caches expiring at once) doesn't turn into one fetch per caller. + jwksFetchGroup singleflight.Group +) + +// jwksCacheKey builds cachedJWKS's cache key from issuerURL and the +// provider's current ThumbprintList, so that changing a provider's +// thumbprints (e.g. after a signing-key or CA compromise) or recreating the +// provider at the same URL with a different ThumbprintList invalidates any +// previously cached key set immediately instead of leaving it reachable for +// up to jwksCacheTTL more. Every call site always supplies the provider's +// current ThumbprintList (freshly read from storage for the request being +// verified), so a changed configuration always maps to a different key here; +// thumbprints are sorted first since storage doesn't guarantee list order is +// stable across reads of an unchanged provider. +func jwksCacheKey(issuerURL string, thumbprints []string) string { + sorted := slices.Clone(thumbprints) + slices.Sort(sorted) + return issuerURL + "|" + strings.Join(sorted, ",") +} + +// cachedJWKS returns issuerURL's key set from cache if a fresh-enough entry +// exists for the current thumbprints, otherwise fetches and caches a fresh +// one. +func cachedJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + jwksCacheMu.Unlock() + if ok && time.Now().Before(entry.expiresAt) { + return entry.keys, nil + } + return fetchAndCacheJWKS(ctx, issuerURL, thumbprints) +} + +// forceRefreshJWKSCache is VerifyWebIdentitySignature's fallback when a +// token's kid matches no cached key: the provider may have rotated its +// signing key since the cache entry was fetched. This bypasses +// expiresAt but not jwksMinForcedRefreshInterval — within that window of a +// previous forced refresh attempt for the same issuer, the still-cached (and +// still non-matching) key set is returned unchanged rather than fetching +// again. Without this gate, an unknown kid alone (no valid signature or +// authentication required to reach this code) would let anyone who knows a +// trusted issuer force one outbound fetch per token by simply varying kid. +// +// lastForcedRefresh is recorded *before* the fetch is attempted, not after a +// success: gating only on success left a failing or slow/unreachable +// issuer with no negative-caching at all — every unknown-kid token would +// re-trigger a fresh outbound fetch (and wait out its own timeout) with no +// backoff, since a failed attempt never set the timestamp that would have +// gated the next one. Recording the attempt up front bounds retries to one +// per jwksMinForcedRefreshInterval regardless of whether the fetch succeeds. +func forceRefreshJWKSCache(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + if ok && time.Since(entry.lastForcedRefresh) < jwksMinForcedRefreshInterval { + jwksCacheMu.Unlock() + if entry.keys == nil { + // The gate is active but there's no key material to fall back + // on — either this is the very first forced refresh for key + // and it hasn't completed yet, or every attempt so far has + // failed. Fail closed instead of returning a nil key set for + // the caller to dereference. + return nil, fmt.Errorf("no cached JWKS available for %q and a recent refresh attempt is still rate-limited", issuerURL) + } + return entry.keys, nil + } + entry.lastForcedRefresh = time.Now() + jwksCache[key] = entry + jwksCacheMu.Unlock() + + return fetchAndCacheJWKS(ctx, issuerURL, thumbprints) +} + +// fetchAndCacheJWKS fetches issuerURL's key set and, on success, replaces +// its cache entry, coalescing concurrent callers for the same issuerURL AND +// thumbprints via jwksFetchGroup (keyed identically to jwksCache, so a +// caller mid-fetch for one thumbprint configuration never receives a result +// coalesced from a differently-configured concurrent caller). +func fetchAndCacheJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + key := jwksCacheKey(issuerURL, thumbprints) + v, err, _ := jwksFetchGroup.Do(key, func() (any, error) { + keys, err := fetchJWKS(ctx, issuerURL, thumbprints) + if err != nil { + return nil, err + } + jwksCacheMu.Lock() + entry := jwksCache[key] + entry.keys = keys + entry.expiresAt = time.Now().Add(jwksCacheTTL) + jwksCache[key] = entry + jwksCacheMu.Unlock() + return keys, nil + }) + if err != nil { + return nil, err + } + return v.(*jwkSet), nil +} + +// fetchJWKS retrieves issuerURL's OIDC discovery document, then the JWKS it +// points to. issuerURL is the provider's stored Url (scheme stripped). +// thumbprints, if non-empty, lets the fetch's TLS connections succeed +// against a self-signed/private-CA certificate whose chain matches one of +// them, the same trust-pinning fallback real AWS documents for OIDC +// providers. +func fetchJWKS(ctx context.Context, issuerURL string, thumbprints []string) (*jwkSet, error) { + client := ssrfSafeHTTPClient(thumbprints) + base := "https://" + issuerURL + + var doc oidcDiscoveryDoc + if err := fetchJSON(ctx, client, strings.TrimRight(base, "/")+"/.well-known/openid-configuration", &doc); err != nil { + return nil, err + } + if err := validateDiscoveryIssuer(doc, issuerURL); err != nil { + return nil, err + } + if !strings.HasPrefix(doc.JWKSUri, "https://") { + return nil, fmt.Errorf("discovery document for %q has non-https jwks_uri %q", issuerURL, doc.JWKSUri) + } + + var keys jwkSet + if err := fetchJSON(ctx, client, doc.JWKSUri, &keys); err != nil { + return nil, err + } + if len(keys.Keys) == 0 { + return nil, fmt.Errorf("no keys published at %q", doc.JWKSUri) + } + if err := enforceJWKSKeyLimits(keys.Keys); err != nil { + return nil, fmt.Errorf("JWKS at %q: %w", doc.JWKSUri, err) + } + return &keys, nil +} + +// enforceJWKSKeyLimits rejects a key set exceeding AWS's documented OIDC +// provider limits (100 RSA and 100 EC keys) before it's cached or iterated +// over by keyFunc on every verification — an oversized or malicious JWKS +// response should fail fast rather than being accepted as a large key set to +// scan on every request. +func enforceJWKSKeyLimits(keys []jwk) error { + var rsaCount, ecCount int + for _, k := range keys { + switch k.Kty { + case "RSA": + rsaCount++ + case "EC": + ecCount++ + } + } + if rsaCount > maxJWKSKeysPerType { + return fmt.Errorf("%d RSA keys exceeds the %d-key limit", rsaCount, maxJWKSKeysPerType) + } + if ecCount > maxJWKSKeysPerType { + return fmt.Errorf("%d EC keys exceeds the %d-key limit", ecCount, maxJWKSKeysPerType) + } + return nil +} + +func fetchJSON(ctx context.Context, client *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d from %q", resp.StatusCode, url) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxOIDCFetchBodyBytes)) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// ssrfSafeHTTPClient returns an http.Client whose transport resolves each +// dial target's DNS once and rejects loopback/private/link-local/multicast +// addresses before connecting, mirroring FetchThumbprint's SSRF guard. It +// applies to every connection the client makes — including ones a redirect +// points at — since Transport.DialContext runs per underlying TCP +// connection, not just for the original request URL. CheckRedirect further +// refuses to follow any redirect whose target isn't https, since Go's +// default client would otherwise happily follow a discovery document (or +// its own redirect chain) down to plaintext http. +// +// TLS certificate verification is replaced with verifyOIDCConnection, which +// accepts a chain that matches one of thumbprints (AWS's documented +// trust-pinning fallback for self-signed/private-CA providers) even when +// standard CA-based verification would otherwise reject it, and falls back +// to ordinary hostname+CA verification against the system root pool +// whenever thumbprints is empty or doesn't match. +func ssrfSafeHTTPClient(thumbprints []string) *http.Client { + dialer := &net.Dialer{} + return &http.Client{ + Timeout: oidcFetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxOIDCFetchRedirects { + return fmt.Errorf("stopped after %d redirects", maxOIDCFetchRedirects) + } + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing to follow non-https redirect to %q", req.URL) + } + return nil + }, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, fmt.Errorf("dns lookup failed for %q", host) + } + for _, ip := range ips { + if isDisallowedFetchTarget(ip) { + return nil, fmt.Errorf("refusing to dial disallowed address %q for host %q", ip, host) + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // verified ourselves via VerifyConnection below + VerifyConnection: func(cs tls.ConnectionState) error { + return verifyOIDCConnection(cs, thumbprints) + }, + }, + }, + } +} + +// verifyOIDCConnection accepts cs's peer certificate chain if the top +// (topmost/intermediate CA) certificate's thumbprint matches any of +// thumbprints AND that certificate, used as the sole trust root, validates +// a signature path to the presented leaf for cs.ServerName — AWS's +// documented trust-pinning fallback trusts certificates *issued by* the +// pinned CA for the expected host, not merely any chain that happens to end +// in a certificate with that thumbprint. Thumbprint equality alone is never +// sufficient: an attacker can append the (non-secret) pinned certificate to +// an unrelated, unsigned chain, so the pinned certificate must also +// cryptographically issue the leaf and the leaf must match cs.ServerName. +// Falls back to standard hostname+CA verification against the system root +// pool whenever thumbprints is empty or none matches. +func verifyOIDCConnection(cs tls.ConnectionState, thumbprints []string) error { + if len(cs.PeerCertificates) == 0 { + return errors.New("iamutil: no certificate presented") + } + + if len(thumbprints) > 0 { + top := cs.PeerCertificates[len(cs.PeerCertificates)-1] + topThumbprint, err := ThumbprintFromChain(cs.PeerCertificates) + if err != nil { + return err + } + for _, pinned := range thumbprints { + if !strings.EqualFold(pinned, topThumbprint) { + continue + } + roots := x509.NewCertPool() + roots.AddCert(top) + opts := x509.VerifyOptions{ + DNSName: cs.ServerName, + Roots: roots, + Intermediates: x509.NewCertPool(), + } + if n := len(cs.PeerCertificates); n > 1 { + for _, cert := range cs.PeerCertificates[1 : n-1] { + opts.Intermediates.AddCert(cert) + } + } + if _, err := cs.PeerCertificates[0].Verify(opts); err == nil { + return nil + } + break + } + } + + opts := x509.VerifyOptions{ + DNSName: cs.ServerName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range cs.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := cs.PeerCertificates[0].Verify(opts) + return err +} diff --git a/iamapi/internal/iamutil/webidentity_test.go b/iamapi/internal/iamutil/webidentity_test.go new file mode 100644 index 00000000..49b53812 --- /dev/null +++ b/iamapi/internal/iamutil/webidentity_test.go @@ -0,0 +1,587 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "math/big" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign test token: %v", err) + } + return signed +} + +func testJWKSet(t *testing.T, key *rsa.PrivateKey, kid string) *jwkSet { + t.Helper() + return &jwkSet{Keys: []jwk{{ + Kty: "RSA", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}} +} + +func TestParseWebIdentityClaims(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + valid := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com", "sub": "user1"}) + + tests := []struct { + name string + token string + wantErr bool + }{ + {name: "valid shape", token: valid}, + {name: "not a jwt", token: "not-a-jwt", wantErr: true}, + {name: "empty", token: "", wantErr: true}, + {name: "two segments", token: "aaaa.bbbb", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, err := ParseWebIdentityClaims(tt.token) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got claims %#v", claims) + } + var apiErr iamerr.Error + if !errors.As(err, &apiErr) || apiErr.Code != "InvalidIdentityToken" { + t.Fatalf("expected InvalidIdentityToken, got %#v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims["iss"] != "https://example.com" { + t.Fatalf("unexpected claims: %#v", claims) + } + }) + } +} + +func TestWebIdentityIssuer(t *testing.T) { + tests := []struct { + claims jwt.MapClaims + want string + wantOk bool + }{ + {claims: jwt.MapClaims{"iss": "https://example.com/path"}, want: "example.com/path", wantOk: true}, + // Not https: left unstripped so it can never coincidentally equal a + // registered (always-https) provider's stored Url. + {claims: jwt.MapClaims{"iss": "http://example.com"}, want: "http://example.com", wantOk: true}, + {claims: jwt.MapClaims{}, wantOk: false}, + {claims: jwt.MapClaims{"iss": ""}, wantOk: false}, + {claims: jwt.MapClaims{"iss": 123}, wantOk: false}, + } + for _, tt := range tests { + got, ok := WebIdentityIssuer(tt.claims) + if ok != tt.wantOk || (ok && got != tt.want) { + t.Errorf("WebIdentityIssuer(%#v) = (%q, %v), want (%q, %v)", tt.claims, got, ok, tt.want, tt.wantOk) + } + } +} + +func TestWebIdentityAudience(t *testing.T) { + tests := []struct { + name string + claims jwt.MapClaims + want string + wantOriginal []string + wantErr bool + }{ + {name: "single string aud", claims: jwt.MapClaims{"aud": "client1"}, want: "client1"}, + {name: "single-element array", claims: jwt.MapClaims{"aud": []any{"client1"}}, want: "client1"}, + {name: "no aud", claims: jwt.MapClaims{}, wantErr: true}, + {name: "empty aud", claims: jwt.MapClaims{"aud": ""}, wantErr: true}, + { + name: "multi aud with matching azp", + claims: jwt.MapClaims{"aud": []any{"other", "client1"}, "azp": "client1"}, + want: "client1", + wantOriginal: []string{"other", "client1"}, + }, + { + name: "multi aud without azp", + claims: jwt.MapClaims{"aud": []any{"other", "client1"}}, + wantErr: true, + }, + { + name: "single aud with azp: azp still wins, original aud exposed", + claims: jwt.MapClaims{ + "aud": "backend-project", "azp": "oauth-client-1", + }, + want: "oauth-client-1", + wantOriginal: []string{"backend-project"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, original, err := WebIdentityAudience(tt.claims) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + if !slices.Equal(original, tt.wantOriginal) { + t.Fatalf("original = %v, want %v", original, tt.wantOriginal) + } + }) + } +} + +func TestWebIdentityAudienceMultipleWithoutAzpMessage(t *testing.T) { + _, _, err := WebIdentityAudience(jwt.MapClaims{"aud": []any{"a", "b"}}) + var apiErr iamerr.Error + if !errors.As(err, &apiErr) { + t.Fatalf("expected iamerr.Error, got %#v", err) + } + if apiErr.Message != "Token audience contains more than one audience while authorized party is not present" { + t.Fatalf("unexpected message: %q", apiErr.Message) + } +} + +func TestVerifyWebIdentityExpiration(t *testing.T) { + now := time.Unix(1_000_000, 0) + + tests := []struct { + name string + exp float64 + wantErr bool + }{ + {name: "not yet expired", exp: float64(now.Unix() + 10)}, + {name: "within leeway", exp: float64(now.Unix() - 200)}, + {name: "expired beyond leeway", exp: float64(now.Unix() - 400), wantErr: true}, + {name: "missing exp", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + if tt.name != "missing exp" { + claims["exp"] = tt.exp + } + err := VerifyWebIdentityExpiration(claims, now) + if tt.wantErr != (err != nil) { + t.Fatalf("VerifyWebIdentityExpiration() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVerifyWebIdentityRequiredClaims(t *testing.T) { + now := time.Unix(1_000_000, 0) + + tests := []struct { + name string + claims jwt.MapClaims + wantErr bool + }{ + {name: "iat and sub present", claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1"}}, + {name: "missing iat", claims: jwt.MapClaims{"sub": "user1"}, wantErr: true}, + {name: "missing sub", claims: jwt.MapClaims{"iat": float64(now.Unix())}, wantErr: true}, + {name: "empty sub", claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": ""}, wantErr: true}, + { + name: "nbf in the past is fine", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() - 10)}, + }, + { + name: "nbf within leeway is fine", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() + 200)}, + }, + { + name: "nbf beyond leeway is not yet valid", + claims: jwt.MapClaims{"iat": float64(now.Unix()), "sub": "user1", "nbf": float64(now.Unix() + 400)}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := VerifyWebIdentityRequiredClaims(tt.claims, now) + if tt.wantErr != (err != nil) { + t.Fatalf("VerifyWebIdentityRequiredClaims() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVerifyOIDCConnection(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + conn, err := tls.Dial("tcp", srv.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + chain := conn.ConnectionState().PeerCertificates + + thumbprint, err := ThumbprintFromChain(chain) + if err != nil { + t.Fatalf("ThumbprintFromChain: %v", err) + } + + t.Run("matching pinned thumbprint bypasses CA trust but still requires a valid chain for the host", func(t *testing.T) { + // The httptest cert's SANs include "example.com" (see + // net/http/internal/testcert), and it is self-signed, so it forms a + // valid one-certificate chain rooted at itself for that name. + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err != nil { + t.Fatalf("expected pinned thumbprint to be accepted for a matching hostname: %v", err) + } + }) + + t.Run("matching pinned thumbprint does not bypass hostname verification", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "totally-different-host.example"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil { + t.Fatal("expected pinned thumbprint to still be rejected for a non-matching hostname") + } + }) + + t.Run("pinned thumbprint match does not bypass chain validation for an appended unrelated leaf", func(t *testing.T) { + // An attacker-controlled leaf (self-signed by a key the pinned CA + // never touched) followed by the real pinned certificate must not + // validate: thumbprint equality alone must not grant trust when the + // pinned certificate never actually issued this leaf. + unrelatedLeaf := generateSelfSignedCert(t, "example.com") + + forged := append([]*x509.Certificate{unrelatedLeaf}, chain...) + cs := tls.ConnectionState{PeerCertificates: forged, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{thumbprint}); err == nil { + t.Fatal("expected forged chain (unrelated leaf + appended pinned cert) to be rejected") + } + }) + + t.Run("non-matching thumbprint falls back to standard verification and fails", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, []string{"0000000000000000000000000000000000000000"}); err == nil { + t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool") + } + }) + + t.Run("no thumbprints falls back to standard verification and fails", func(t *testing.T) { + cs := tls.ConnectionState{PeerCertificates: chain, ServerName: "example.com"} + if err := verifyOIDCConnection(cs, nil); err == nil { + t.Fatal("expected standard verification to fail for a self-signed cert not in the system pool") + } + }) + + t.Run("no certificates presented", func(t *testing.T) { + if err := verifyOIDCConnection(tls.ConnectionState{}, nil); err == nil { + t.Fatal("expected error when no certificate is presented") + } + }) +} + +func TestVerifySignatureWithKeys(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate other key: %v", err) + } + + keys := testJWKSet(t, key, "k1") + + t.Run("valid signature", func(t *testing.T) { + token := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com", "sub": "u1"}) + claims, err := verifySignatureWithKeys(token, keys) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims["sub"] != "u1" { + t.Fatalf("unexpected claims: %#v", claims) + } + }) + + t.Run("wrong signing key", func(t *testing.T) { + token := signTestToken(t, otherKey, "k1", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err == nil { + t.Fatal("expected signature verification failure") + } + }) + + t.Run("no kid in token, single key set still matches", func(t *testing.T) { + token := signTestToken(t, key, "", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err != nil { + t.Fatalf("single-key JWKS should match a token with no kid: %v", err) + } + }) + + t.Run("mismatched kid against single key set fails", func(t *testing.T) { + token := signTestToken(t, key, "unknown-kid", jwt.MapClaims{"iss": "https://example.com"}) + if _, err := verifySignatureWithKeys(token, keys); err == nil { + t.Fatal("a kid that doesn't match the single known key should not be accepted") + } + }) + + t.Run("multi-key set reports errUnknownKID for an unrecognized kid", func(t *testing.T) { + multiKeySet := testJWKSet(t, key, "k1") + multiKeySet.Keys = append(multiKeySet.Keys, testJWKSet(t, otherKey, "k2").Keys[0]) + + token := signTestToken(t, key, "unknown-kid", jwt.MapClaims{"iss": "https://example.com"}) + _, err := verifySignatureWithKeys(token, multiKeySet) + if !errors.Is(err, errUnknownKID) { + t.Fatalf("expected errUnknownKID, got %v", err) + } + }) + + t.Run("tampered payload", func(t *testing.T) { + token := signTestToken(t, key, "k1", jwt.MapClaims{"iss": "https://example.com"}) + tampered := token[:len(token)-4] + "AAAA" + if _, err := verifySignatureWithKeys(tampered, keys); err == nil { + t.Fatal("expected tampered token to fail verification") + } + }) +} + +func TestRoleNameFromAssumeArn(t *testing.T) { + const account = "000000000000" + + tests := []struct { + name string + arn string + wantName string + wantFound bool + }{ + {name: "simple", arn: "arn:aws:iam::000000000000:role/my-role", wantName: "my-role", wantFound: true}, + {name: "with path", arn: "arn:aws:iam::000000000000:role/path/to/my-role", wantName: "my-role", wantFound: true}, + {name: "wrong account", arn: "arn:aws:iam::111111111111:role/my-role", wantFound: false}, + {name: "wrong resource type", arn: "arn:aws:iam::000000000000:user/my-user", wantFound: false}, + {name: "not an arn", arn: "not-an-arn", wantFound: false}, + {name: "empty resource", arn: "arn:aws:iam::000000000000:role/", wantFound: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := RoleNameFromAssumeArn(tt.arn, account) + if ok != tt.wantFound || (ok && got != tt.wantName) { + t.Errorf("RoleNameFromAssumeArn(%q) = (%q, %v), want (%q, %v)", tt.arn, got, ok, tt.wantName, tt.wantFound) + } + }) + } +} + +func TestValidateRoleSessionName(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "valid", value: "my-session_1.2@3"}, + {name: "too short", value: "a", wantErr: true}, + {name: "too long", value: string(make([]byte, 65)), wantErr: true}, + {name: "invalid chars", value: "bad session!!", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRoleSessionName(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateRoleSessionName(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestExtractClaimContext(t *testing.T) { + claims := jwt.MapClaims{ + "iss": "https://example.com", + "aud": "client1", + "sub": "user1", + "exp": float64(1000), + "amr": []any{"pwd", "mfa"}, + "groups": "admins", + // golang-jwt decodes every JSON number as float64 and every JSON + // bool as bool - without claimScalarString handling both, a Bool or + // Numeric trust-policy Condition against a custom claim like these + // would silently never match, since the claim would never reach + // the output map at all (the key would always look "absent"). + "tier": float64(3), + "admin": true, + "scores": []any{float64(1), "x", true}, + } + got := ExtractClaimContext(claims) + + if _, ok := got["iss"]; ok { + t.Errorf("well-known claim iss should be excluded, got %#v", got) + } + if got["groups"][0] != "admins" { + t.Errorf("unexpected groups value: %#v", got["groups"]) + } + if len(got["amr"]) != 2 || got["amr"][0] != "pwd" || got["amr"][1] != "mfa" { + t.Errorf("unexpected amr value: %#v", got["amr"]) + } + if len(got["tier"]) != 1 || got["tier"][0] != "3" { + t.Errorf("unexpected tier value: %#v", got["tier"]) + } + if len(got["admin"]) != 1 || got["admin"][0] != "true" { + t.Errorf("unexpected admin value: %#v", got["admin"]) + } + if len(got["scores"]) != 3 || got["scores"][0] != "1" || got["scores"][1] != "x" || got["scores"][2] != "true" { + t.Errorf("unexpected scores value: %#v", got["scores"]) + } +} + +func TestBuildAssumedRoleArn(t *testing.T) { + got := BuildAssumedRoleArn("000000000000", "my-role", "my-session") + want := "arn:aws:sts::000000000000:assumed-role/my-role/my-session" + if got != want { + t.Errorf("BuildAssumedRoleArn() = %q, want %q", got, want) + } +} + +// generateSelfSignedCert returns a freshly generated, self-signed +// certificate for dnsName, signed by a key unrelated to any other +// certificate in the test — used to simulate an attacker-controlled leaf +// that a real pinned CA never issued. +func generateSelfSignedCert(t *testing.T, dnsName string) *x509.Certificate { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{dnsName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return cert +} + +func TestValidateDiscoveryIssuer(t *testing.T) { + tests := []struct { + name string + doc oidcDiscoveryDoc + issuerURL string + wantErr bool + }{ + {name: "matching issuer", doc: oidcDiscoveryDoc{Issuer: "https://example.com"}, issuerURL: "example.com", wantErr: false}, + {name: "mismatched issuer", doc: oidcDiscoveryDoc{Issuer: "https://attacker.example"}, issuerURL: "example.com", wantErr: true}, + {name: "missing issuer", doc: oidcDiscoveryDoc{Issuer: ""}, issuerURL: "example.com", wantErr: true}, + {name: "issuer with different path is not an exact match", doc: oidcDiscoveryDoc{Issuer: "https://example.com/tenant"}, issuerURL: "example.com", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDiscoveryIssuer(tt.doc, tt.issuerURL) + if (err != nil) != tt.wantErr { + t.Fatalf("validateDiscoveryIssuer(%+v, %q) error = %v, wantErr %v", tt.doc, tt.issuerURL, err, tt.wantErr) + } + }) + } +} + +func TestJWKSCacheKeyBindsThumbprints(t *testing.T) { + base := jwksCacheKey("example.com", []string{"aaaa"}) + + if got := jwksCacheKey("example.com", []string{"bbbb"}); got == base { + t.Errorf("jwksCacheKey did not change when thumbprint changed: %q", got) + } + if got := jwksCacheKey("example.com", nil); got == base { + t.Errorf("jwksCacheKey did not change when thumbprint was removed: %q", got) + } + if got := jwksCacheKey("other.example.com", []string{"aaaa"}); got == base { + t.Errorf("jwksCacheKey did not change when issuer changed: %q", got) + } + // Storage doesn't guarantee ThumbprintList order is stable across reads + // of an unchanged provider, so the key must not depend on input order. + if got := jwksCacheKey("example.com", []string{"bbbb", "aaaa"}); got != jwksCacheKey("example.com", []string{"aaaa", "bbbb"}) { + t.Errorf("jwksCacheKey is sensitive to thumbprint order: %q", got) + } +} + +func TestForceRefreshJWKSCacheGatesFailedAttempts(t *testing.T) { + issuer := "localhost" + key := jwksCacheKey(issuer, nil) + jwksCacheMu.Lock() + delete(jwksCache, key) + jwksCacheMu.Unlock() + t.Cleanup(func() { + jwksCacheMu.Lock() + delete(jwksCache, key) + jwksCacheMu.Unlock() + }) + + ctx := context.Background() + + if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil { + t.Fatal("forceRefreshJWKSCache() = nil error, want an error for a disallowed loopback target") + } + + jwksCacheMu.Lock() + entry, ok := jwksCache[key] + jwksCacheMu.Unlock() + if !ok || entry.lastForcedRefresh.IsZero() { + t.Fatal("forceRefreshJWKSCache did not record lastForcedRefresh for a failed attempt") + } + before := entry.lastForcedRefresh + + // A second forced refresh within jwksMinForcedRefreshInterval must be + // gated - failing immediately with no cached keys to fall back on - + // rather than attempting another fetch. + if _, err := forceRefreshJWKSCache(ctx, issuer, nil); err == nil { + t.Fatal("forceRefreshJWKSCache() = nil error on gated retry, want an error (no cached keys available)") + } + jwksCacheMu.Lock() + after := jwksCache[key].lastForcedRefresh + jwksCacheMu.Unlock() + if !after.Equal(before) { + t.Errorf("forceRefreshJWKSCache re-attempted a fetch within jwksMinForcedRefreshInterval: lastForcedRefresh changed from %v to %v", before, after) + } +} diff --git a/iamapi/policy/condition.go b/iamapi/policy/condition.go new file mode 100644 index 00000000..64fa9a86 --- /dev/null +++ b/iamapi/policy/condition.go @@ -0,0 +1,500 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "regexp" + "strconv" + "strings" + "time" + + "github.com/versity/versitygw/debuglogger" +) + +// ConditionValues decodes the value(s) of a single Condition operator/key +// pair. Unlike Action/Resource's string-only StringOrSlice, a Condition +// value may also be a bare JSON number or boolean rather than +// being re-serialized, so e.g. "5.50" round-trips as "5.50", not "5.5". A +// JSON null value or a non-scalar (object/array) element is rejected. +type ConditionValues []string + +func (c *ConditionValues) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '[' { + var raws []json.RawMessage + if err := json.Unmarshal(trimmed, &raws); err != nil { + return err + } + values := make([]string, len(raws)) + for i, r := range raws { + s, ok := decodeConditionScalar(r) + if !ok { + return fmt.Errorf("policy: invalid condition value %s", r) + } + values[i] = s + } + *c = values + return nil + } + + s, ok := decodeConditionScalar(trimmed) + if !ok { + return fmt.Errorf("policy: invalid condition value %s", trimmed) + } + *c = ConditionValues{s} + return nil +} + +// decodeConditionScalar decodes a single JSON scalar (string, number, or +// bool) to its string form, rejecting null and any non-scalar (object, +// array) value. +func decodeConditionScalar(raw json.RawMessage) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return "", false + } + if trimmed[0] == '"' { + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return "", false + } + return s, true + } + switch string(trimmed) { + case "true", "false": + return string(trimmed), true + case "null": + return "", false + } + var num json.Number + if err := json.Unmarshal(trimmed, &num); err != nil { + return "", false + } + return num.String(), true +} + +// conditionQualifier is IAM's multivalued-context-key set operator, given as +// a "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name. +type conditionQualifier int + +const ( + qualifierNone conditionQualifier = iota + qualifierForAllValues + qualifierForAnyValue +) + +// conditionComparator is a single (policy value, request value) match test +// for one condition operator family, e.g. string equality or a numeric +// comparison. It never itself accounts for absence, IfExists, negation, or +// multivalued aggregation - those are handled by evaluateConditionKey and +// aggregate around it. +type conditionComparator func(expected, actual string) bool + +// conditionOperatorDef is a recognized condition operator's evaluation +// behavior: negate distinguishes a Not-family operator (StringNotEquals, +// ArnNotEquals, ...) from its positive counterpart - both share the same +// comparator, since "not equal" is just the equality test used differently +// (see aggregate), not a different comparison. +type conditionOperatorDef struct { + compare conditionComparator + negate bool +} + +// conditionRegistry is every condition operator base name this package +// recognizes, except "Null" (handled separately by evaluateNull - it has no +// value comparator at all, only a presence check). Populated below from +// AWS's documented condition operator reference. +var conditionRegistry = map[string]conditionOperatorDef{ + "StringEquals": {compare: stringExact}, + "StringNotEquals": {compare: stringExact, negate: true}, + "StringEqualsIgnoreCase": {compare: stringFold}, + "StringNotEqualsIgnoreCase": {compare: stringFold, negate: true}, + "StringLike": {compare: stringLike}, + "StringNotLike": {compare: stringLike, negate: true}, + + "NumericEquals": {compare: numericCompare(func(a, e float64) bool { return a == e })}, + "NumericNotEquals": {compare: numericCompare(func(a, e float64) bool { return a == e }), negate: true}, + "NumericLessThan": {compare: numericCompare(func(a, e float64) bool { return a < e })}, + "NumericLessThanEquals": {compare: numericCompare(func(a, e float64) bool { return a <= e })}, + "NumericGreaterThan": {compare: numericCompare(func(a, e float64) bool { return a > e })}, + "NumericGreaterThanEquals": {compare: numericCompare(func(a, e float64) bool { return a >= e })}, + + "DateEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) })}, + "DateNotEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) }), negate: true}, + "DateLessThan": {compare: dateCompare(func(a, e time.Time) bool { return a.Before(e) })}, + "DateLessThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.After(e) })}, + "DateGreaterThan": {compare: dateCompare(func(a, e time.Time) bool { return a.After(e) })}, + "DateGreaterThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.Before(e) })}, + + "Bool": {compare: boolMatch}, + + "BinaryEquals": {compare: binaryMatch}, + + // ArnEquals and ArnLike behave identically in real AWS (both wildcard + // -aware), and are matched here with the same whole-string globMatch + // already used for Action/Resource - do not "fix" ArnEquals to a strict + // == later, that would diverge from AWS behavior. + "ArnEquals": {compare: stringLike}, + "ArnLike": {compare: stringLike}, + "ArnNotEquals": {compare: stringLike, negate: true}, + "ArnNotLike": {compare: stringLike, negate: true}, + + "IpAddress": {compare: ipMatch}, + "NotIpAddress": {compare: ipMatch, negate: true}, +} + +func stringExact(expected, actual string) bool { return expected == actual } +func stringFold(expected, actual string) bool { return strings.EqualFold(expected, actual) } +func stringLike(expected, actual string) bool { return globMatch(expected, actual) } + +// numericCompare builds a comparator from a (actual, expected float64) -> +// bool test, matching AWS's direction convention (the request's value is +// compared against the policy's value). Either operand failing to parse as +// a number fails the comparison rather than erroring +func numericCompare(op func(actual, expected float64) bool) conditionComparator { + return func(expected, actual string) bool { + e, eerr := strconv.ParseFloat(expected, 64) + a, aerr := strconv.ParseFloat(actual, 64) + return eerr == nil && aerr == nil && op(a, e) + } +} + +// dateCompare builds a comparator from a (actual, expected time.Time) -> +// bool test, same direction convention as numericCompare. +func dateCompare(op func(actual, expected time.Time) bool) conditionComparator { + return func(expected, actual string) bool { + e, eok := parseConditionDate(expected) + a, aok := parseConditionDate(actual) + return eok && aok && op(a, e) + } +} + +// parseConditionDate parses a Date condition operand in either form AWS +// accepts: an RFC 3339 date-time, or Unix epoch seconds (optionally +// fractional). +func parseConditionDate(s string) (time.Time, bool) { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, true + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t, true + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + sec := int64(f) + nsec := int64((f - float64(sec)) * 1e9) + return time.Unix(sec, nsec).UTC(), true + } + return time.Time{}, false +} + +func boolMatch(expected, actual string) bool { + e, eerr := strconv.ParseBool(expected) + a, aerr := strconv.ParseBool(actual) + return eerr == nil && aerr == nil && e == a +} + +func binaryMatch(expected, actual string) bool { + e, eerr := base64.StdEncoding.DecodeString(expected) + a, aerr := base64.StdEncoding.DecodeString(actual) + return eerr == nil && aerr == nil && bytes.Equal(e, a) +} + +// ipMatch reports whether actual (an address) falls within cidr (a CIDR +// range, or an exact address treated as a /32 or /128), matching IAM's +// IpAddress/NotIpAddress condition operators. An unparseable operand on +// either side never matches (fails closed) rather than erroring. +func ipMatch(cidr, actual string) bool { + c := cidr + if !strings.Contains(c, "/") { + if ip := net.ParseIP(c); ip != nil && ip.To4() != nil { + c += "/32" + } else { + c += "/128" + } + } + _, network, err := net.ParseCIDR(c) + if err != nil { + return false + } + ip := net.ParseIP(actual) + return ip != nil && network.Contains(ip) +} + +// parsedOperator is a condition operator name decomposed into its set +// qualifier, base operator, and IfExists flag. +type parsedOperator struct { + qualifier conditionQualifier + base string + ifExists bool +} + +// parseOperatorName decomposes name (e.g. "ForAllValues:StringNotEqualsIfExists") +// into a parsedOperator, reporting ok=false if the base operator (after +// stripping a recognized qualifier prefix and IfExists suffix) isn't one +// conditionRegistry recognizes, or is "Null" (Null has no IfExists variant - +// "NullIfExists" is rejected here since after suffix-stripping "Null" isn't +// itself in conditionRegistry). A bare "Null", optionally qualifier-prefixed, is accepted +func parseOperatorName(name string) (parsedOperator, bool) { + op := name + qualifier := qualifierNone + switch { + case strings.HasPrefix(op, "ForAllValues:"): + qualifier = qualifierForAllValues + op = strings.TrimPrefix(op, "ForAllValues:") + case strings.HasPrefix(op, "ForAnyValue:"): + qualifier = qualifierForAnyValue + op = strings.TrimPrefix(op, "ForAnyValue:") + } + + if op == "Null" { + return parsedOperator{qualifier: qualifier, base: "Null"}, true + } + + base := strings.TrimSuffix(op, "IfExists") + ifExists := base != op + if _, ok := conditionRegistry[base]; !ok { + return parsedOperator{}, false + } + return parsedOperator{qualifier: qualifier, base: base, ifExists: ifExists}, true +} + +// conditionShapeValid checks raw (a statement's Condition block) against +// IAM's condition grammar for write-time validation: an object of operator +// -> (key -> value), where every operator name is recognized by +// parseOperatorName. An absent, null, or empty Condition is valid (matches +// evaluateCondition's "always matches" contract). +func conditionShapeValid(raw json.RawMessage) bool { + if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return true + } + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + return false + } + for operator := range block { + if _, ok := parseOperatorName(operator); !ok { + return false + } + } + return true +} + +// conditionVariableOperators is the subset of conditionRegistry that AWS +// documents as supporting ${...} policy-variable substitution in a +// Condition value: the String family and the Arn family (both ultimately +// whole-string comparisons). AWS's policy-variable documentation +// specifically excludes Numeric, Date, Boolean, Binary, IP address, and +// Null operators - a variable placed there is never substituted, regardless +// of document version. +var conditionVariableOperators = map[string]bool{ + "StringEquals": true, + "StringNotEquals": true, + "StringEqualsIgnoreCase": true, + "StringNotEqualsIgnoreCase": true, + "StringLike": true, + "StringNotLike": true, + "ArnEquals": true, + "ArnLike": true, + "ArnNotEquals": true, + "ArnNotLike": true, +} + +// evaluateCondition evaluates a policy statement's Condition block against +// ctxVars - a ":" keyed context for trust-policy +// evaluation, or an "aws:" keyed context for identity-policy +// evaluation. An absent or empty Condition always matches. version is the +// enclosing document's Version element: a ${...} policy variable in a +// Condition value is only ever substituted when version is exactly +// Version2012 AND the operator is one of conditionVariableOperators - +// AWS requires the 2012-10-17 policy version to use variables at all, and +// never expands them for Numeric/Date/Bool/Binary/IP/Null operators even +// then. A variable that doesn't qualify is left as literal text, the +// same fallback used for an absent/multivalued context key - so it simply +// won't match a real condition value, rather than silently expanding into +// something AWS itself wouldn't. +// +// matched reports whether the condition holds; ok reports whether it could +// be evaluated at all. ok is false only for a Condition block whose JSON +// shape or operator name conditionShapeValid would already reject - i.e. +// only for a document stored before that write-time validation existed, or +// containing a future operator this package doesn't yet recognize. Callers +// MUST treat ok=false as "cannot rule out a hidden Deny" and deny the whole +// evaluation, never as a non-match - see EvaluateIdentityPolicies and +// EvaluateWebIdentityTrust. +func evaluateCondition(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) { + if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return true, true + } + + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + debuglogger.Logf("policy condition block failed to parse: %v", err) + return false, false + } + + for operator, kvs := range block { + op, recognized := parseOperatorName(operator) + if !recognized { + debuglogger.Logf("policy condition: unrecognized operator %q", operator) + return false, false + } + for key, expected := range kvs { + actual, present := lookupContextValues(ctxVars, key) + if version == Version2012 && conditionVariableOperators[op.base] { + expected = substituteConditionValues(expected, ctxVars) + } + if !evaluateConditionKey(op, expected, actual, present) { + return false, true + } + } + } + return true, true +} + +// lookupContextValues retrieves ctxVars[key], matching key +// case-insensitively: AWS documents condition (and policy-variable) key +// *names* as case-insensitive - "aws:SourceIp" and "AWS:SOURCEIP" name the +// same key - even though the values held under that key remain +// case-sensitive. An exact match is tried first so the common case doesn't +// pay for a map scan. +func lookupContextValues(ctxVars map[string][]string, key string) ([]string, bool) { + if v, ok := ctxVars[key]; ok { + return v, true + } + for k, v := range ctxVars { + if strings.EqualFold(k, key) { + return v, true + } + } + return nil, false +} + +// policyVariablePattern matches a single "${...}" policy-variable +// placeholder, e.g. "${aws:username}". +var policyVariablePattern = regexp.MustCompile(`\$\{([A-Za-z0-9_:.\-]+)\}`) + +// substitutePolicyVariables replaces every ${key} placeholder in s with the +// single value ctxVars holds for key, looked up the same case-insensitive +// way as a Condition key. AWS only allows a single-valued context key to be +// used as a policy variable; a placeholder naming an absent or multivalued +// key is left as literal text, same as any other substring - so it simply +// won't match a real resource ARN or condition value, rather than being +// silently dropped and turning a Deny that relies on it into a no-op. +func substitutePolicyVariables(s string, ctxVars map[string][]string) string { + if !strings.Contains(s, "${") { + return s + } + return policyVariablePattern.ReplaceAllStringFunc(s, func(match string) string { + key := match[2 : len(match)-1] + values, ok := lookupContextValues(ctxVars, key) + if !ok || len(values) != 1 { + return match + } + return values[0] + }) +} + +// substituteConditionValues applies substitutePolicyVariables to every +// element of values, so e.g. a Condition of +// {"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}} compares +// against the requester's own username rather than the literal text. +func substituteConditionValues(values ConditionValues, ctxVars map[string][]string) ConditionValues { + out := make(ConditionValues, len(values)) + for i, v := range values { + out[i] = substitutePolicyVariables(v, ctxVars) + } + return out +} + +// evaluateConditionKey evaluates one operator/key pair of an already +// -parsed Condition block against actual (ctxVars[key]) and present +// (whether key was in ctxVars at all). +func evaluateConditionKey(op parsedOperator, expected ConditionValues, actual []string, present bool) bool { + if op.base == "Null" { + return evaluateNull(expected, present) + } + entry := conditionRegistry[op.base] // guaranteed present - parseOperatorName already validated op.base + + if op.qualifier == qualifierForAllValues && !present { + return true + } + if entry.negate { + if !present { + return true + } + return aggregate(op.qualifier, true, expected, actual, entry.compare) + } + if !present { + return op.ifExists + } + return aggregate(op.qualifier, false, expected, actual, entry.compare) +} + +// evaluateNull implements the Null condition operator: true if expected +// (normally exactly one of "true"/"false", case-insensitive) says the key +// must be absent ("true") and it is, or must be present ("false") and it +// is. A value that's neither "true" nor "false" never satisfies the +// condition (fails closed) +func evaluateNull(expected ConditionValues, present bool) bool { + for _, e := range expected { + switch { + case strings.EqualFold(e, "true"): + if !present { + return true + } + case strings.EqualFold(e, "false"): + if present { + return true + } + } + } + return false +} + +// aggregate reports whether expected/actual satisfy a condition-key match +// under qualifier's multivalued-context-key semantics. negate selects the +// Not-operator family, sharing the same per-pair comparator as its positive +// counterpart (see conditionRegistry). +func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValues, actual []string, cmp conditionComparator) bool { + matchesAny := func(a string) bool { + for _, e := range expected { + if cmp(e, a) { + return true + } + } + return false + } + + useForAll := qualifier == qualifierForAllValues || (qualifier == qualifierNone && negate) + if useForAll { + for _, a := range actual { + if ok := matchesAny(a); ok == negate { + return false + } + } + return true // vacuously true over an empty/absent actual + } + for _, a := range actual { + if ok := matchesAny(a); ok != negate { + return true + } + } + return false // vacuously false over an empty/absent actual +} diff --git a/iamapi/policy/condition_test.go b/iamapi/policy/condition_test.go new file mode 100644 index 00000000..121d4839 --- /dev/null +++ b/iamapi/policy/condition_test.go @@ -0,0 +1,761 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "reflect" + "testing" +) + +// evalCondTest is the shared table shape for every TestEvaluateCondition* +// function below. wantErr means "evaluateCondition's ok return should be +// false" (the block's shape or an operator name couldn't be recognized) - +// distinct from want=false, which means the condition was evaluated fine +// but didn't match. +type evalCondTest struct { + name string + raw string + ctxVars map[string][]string + // version is the enclosing document's Version element: a Condition + // value's ${...} policy variable is only ever substituted + // when this is exactly Version2012. Left "" (no Version) for every + // existing case except the ones specifically testing substitution. + version string + want bool + wantErr bool +} + +func runEvalCondTests(t *testing.T, tests []evalCondTest) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, ok := evaluateCondition([]byte(tt.raw), tt.ctxVars, tt.version) + wantOk := !tt.wantErr + if ok != wantOk { + t.Fatalf("evaluateCondition() ok = %v, want %v", ok, wantOk) + } + if ok && matched != tt.want { + t.Errorf("evaluateCondition() matched = %v, want %v", matched, tt.want) + } + }) + } +} + +func TestEvaluateCondition(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + {name: "empty condition always matches", raw: ``, want: true}, + { + name: "StringEquals matches", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: true, + }, + { + name: "StringEquals mismatch", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: false, + }, + { + name: "StringEquals missing key fails closed", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: "StringEquals against multivalued context matches any", + raw: `{"StringEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other", "client1"}}, + want: true, + }, + { + name: "StringEquals against multivalued condition matches any", + raw: `{"StringEquals":{"example.com:aud":["client1","client2"]}}`, + ctxVars: map[string][]string{"example.com:aud": {"client2"}}, + want: true, + }, + { + name: "StringNotEquals matches when different", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: true, + }, + { + name: "StringNotEquals fails when equal", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: false, + }, + { + name: "StringNotEquals matches when key absent", + raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringNotEqualsIfExists is accepted and behaves like StringNotEquals", + raw: `{"StringNotEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}}, + want: false, + }, + { + name: "StringLike wildcard matches", + raw: `{"StringLike":{"example.com:sub":"user-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: true, + }, + { + name: "StringLike wildcard mismatch", + raw: `{"StringLike":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: false, + }, + { + name: "StringLikeIfExists enforces match when key present", + raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: false, + }, + { + name: "StringLikeIfExists passes when key absent", + raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringNotLike matches when pattern doesn't match", + raw: `{"StringNotLike":{"example.com:sub":"admin-*"}}`, + ctxVars: map[string][]string{"example.com:sub": {"user-123"}}, + want: true, + }, + { + name: "StringEqualsIgnoreCase matches regardless of case", + raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"alice"}}, + want: true, + }, + { + name: "StringEqualsIgnoreCase mismatch", + raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"bob"}}, + want: false, + }, + { + name: "StringNotEqualsIgnoreCase matches when different regardless of case", + raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"bob"}}, + want: true, + }, + { + name: "StringNotEqualsIgnoreCase fails when equal regardless of case", + raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`, + ctxVars: map[string][]string{"example.com:sub": {"alice"}}, + want: false, + }, + { + name: "StringEqualsIfExists passes when key absent", + raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "StringEqualsIfExists enforces match when key present", + raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`, + ctxVars: map[string][]string{"example.com:aud": {"other"}}, + want: false, + }, + { + name: "multiple operators must all pass", + raw: `{"StringEquals":{"example.com:aud":"client1"},"StringLike":{"example.com:sub":"user-*"}}`, + ctxVars: map[string][]string{"example.com:aud": {"client1"}, "example.com:sub": {"user-1"}}, + want: true, + }, + { + name: "unrecognized operator fails closed", + raw: `{"FooBarOperator":{"example.com:level":"1"}}`, + ctxVars: map[string][]string{"example.com:level": {"1"}}, + wantErr: true, + }, + { + name: "malformed condition JSON fails closed", + raw: `not json`, + wantErr: true, + }, + { + name: "malformed condition block shape (operator value not an object) fails closed", + raw: `{"StringEquals":"not an object"}`, + wantErr: true, + }, + { + name: "malformed condition block shape (operator value is an array) fails closed", + raw: `{"StringEquals":["not","a","map"]}`, + wantErr: true, + }, + { + // Condition key *names* are case-insensitive in AWS, even + // though the values they hold remain case-sensitive. + name: "condition key name matches case-insensitively", + raw: `{"StringEquals":{"AWS:UserName":"alice"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: true, + }, + { + name: "condition key name case-insensitive match still compares values case-sensitively", + raw: `{"StringEquals":{"AWS:UserName":"Alice"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + // A policy variable in a Condition value is substituted from + // the request context before comparing, the same as a + // Resource pattern. + name: "policy variable in condition value is substituted under version 2012-10-17", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}}, + version: Version2012, + want: true, + }, + { + name: "policy variable naming an absent key is left literal and so fails to match", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:nonexistent}"}}`, + ctxVars: map[string][]string{"iam:ResourceTag/owner": {"alice"}}, + version: Version2012, + want: false, + }, + { + // Without an explicit 2012-10-17 Version, AWS does not expand + // policy variables at all - the "${aws:username}" text is + // compared literally and so never matches a real tag value. + name: "policy variable is not substituted without version 2012-10-17", + raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}}, + want: false, + }, + { + // AWS never expands policy variables inside Numeric/Date/ + // Bool/Binary/IP/Null operators, even under version 2012-10-17 - + // a NumericEquals comparing aws:EpochTime against a literal + // "${aws:EpochTime}" never self-matches. + name: "policy variable is not substituted inside NumericEquals even under version 2012-10-17", + raw: `{"NumericEquals":{"aws:EpochTime":"${aws:EpochTime}"}}`, + ctxVars: map[string][]string{"aws:EpochTime": {"1700000000"}}, + version: Version2012, + want: false, + }, + }) +} + +func TestEvaluateConditionNumeric(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "NumericEquals matches", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericEquals mismatch", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"6"}}, + want: false, + }, + { + name: "NumericEquals accepts a bare JSON number condition value", + raw: `{"NumericEquals":{"s3:max-keys":5}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericEquals unparseable actual operand fails closed, not an error", + raw: `{"NumericEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"not-a-number"}}, + want: false, + }, + { + name: "NumericNotEquals matches when different", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"6"}}, + want: true, + }, + { + name: "NumericNotEquals fails when equal", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericNotEquals matches when key absent", + raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "NumericLessThan matches", + raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"3"}}, + want: true, + }, + { + name: "NumericLessThan boundary does not match", + raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericLessThanEquals boundary matches", + raw: `{"NumericLessThanEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericGreaterThan matches", + raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"7"}}, + want: true, + }, + { + name: "NumericGreaterThan boundary does not match", + raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: false, + }, + { + name: "NumericGreaterThanEquals boundary matches", + raw: `{"NumericGreaterThanEquals":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{"s3:max-keys": {"5"}}, + want: true, + }, + { + name: "NumericGreaterThanEqualsIfExists passes when key absent", + raw: `{"NumericGreaterThanEqualsIfExists":{"s3:max-keys":"5"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionDate(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "DateEquals matches same instant in RFC3339", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateEquals matches across RFC3339 vs epoch-seconds formats", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"1704067200"}}, + want: true, + }, + { + name: "DateEquals mismatch", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: false, + }, + { + name: "DateNotEquals matches when different", + raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateNotEquals matches when key absent", + raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "DateLessThan matches", + raw: `{"DateLessThan":{"aws:CurrentTime":"2024-06-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateGreaterThan matches", + raw: `{"DateGreaterThan":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateGreaterThanEquals boundary matches", + raw: `{"DateGreaterThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "DateLessThanEquals boundary matches", + raw: `{"DateLessThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}}, + want: true, + }, + { + name: "Date operator unparseable operand fails closed, not an error", + raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`, + ctxVars: map[string][]string{"aws:CurrentTime": {"not-a-date"}}, + want: false, + }, + }) +} + +func TestEvaluateConditionBool(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "Bool matches", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"true"}}, + want: true, + }, + { + name: "Bool mismatch", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"false"}}, + want: false, + }, + { + name: "Bool absent key fails closed", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: "BoolIfExists passes when key absent", + raw: `{"BoolIfExists":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "Bool garbage value fails closed, not an error", + raw: `{"Bool":{"example.com:admin":"true"}}`, + ctxVars: map[string][]string{"example.com:admin": {"yes"}}, + want: false, + }, + { + name: "Bool accepts a bare JSON boolean condition value", + raw: `{"Bool":{"example.com:admin":true}}`, + ctxVars: map[string][]string{"example.com:admin": {"true"}}, + want: true, + }, + }) +} + +func TestEvaluateConditionBinary(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "BinaryEquals matches", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"aGVsbG8="}}, + want: true, + }, + { + name: "BinaryEquals mismatch", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"d29ybGQ="}}, + want: false, + }, + { + name: "BinaryEquals invalid base64 fails closed, not an error", + raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`, + ctxVars: map[string][]string{"example.com:token": {"not-valid-base64!!"}}, + want: false, + }, + }) +} + +func TestEvaluateConditionArn(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "ArnLike wildcard matches", + raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: true, + }, + { + name: "ArnLike cross-account mismatch", + raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}}, + want: false, + }, + { + name: "ArnEquals behaves identically to ArnLike (wildcard-aware)", + raw: `{"ArnEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: true, + }, + { + name: "ArnNotLike matches a non-matching ARN", + raw: `{"ArnNotLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}}, + want: true, + }, + { + name: "ArnNotEquals fails when the ARN matches", + raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}}, + want: false, + }, + { + name: "ArnNotEquals matches when key absent", + raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionIP(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "IpAddress CIDR matches", + raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}, + want: true, + }, + { + name: "IpAddress CIDR mismatch", + raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: false, + }, + { + name: "IpAddress exact address treated as /32", + raw: `{"IpAddress":{"aws:SourceIp":"203.0.113.5"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: true, + }, + { + name: "NotIpAddress matches an address outside the range", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}, + want: true, + }, + { + name: "NotIpAddress fails for an address inside the range", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}, + want: false, + }, + { + name: "NotIpAddress matches when key absent", + raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + }) +} + +func TestEvaluateConditionNull(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: `Null "true" matches when key absent`, + raw: `{"Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: `Null "true" fails when key present`, + raw: `{"Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + name: `Null "false" fails when key absent`, + raw: `{"Null":{"aws:username":"false"}}`, + ctxVars: map[string][]string{}, + want: false, + }, + { + name: `Null "false" matches when key present`, + raw: `{"Null":{"aws:username":"false"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: true, + }, + { + name: "Null garbage value never satisfies", + raw: `{"Null":{"aws:username":"maybe"}}`, + ctxVars: map[string][]string{"aws:username": {"alice"}}, + want: false, + }, + { + name: "ForAllValues:Null is accepted and behaves like plain Null", + raw: `{"ForAllValues:Null":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "NullIfExists is rejected - Null has no IfExists variant", + raw: `{"NullIfExists":{"aws:username":"true"}}`, + ctxVars: map[string][]string{}, + wantErr: true, + }, + }) +} +func TestEvaluateConditionQualifiers(t *testing.T) { + runEvalCondTests(t, []evalCondTest{ + { + name: "unqualified StringNotEquals denies when any actual value matches (pre-existing behavior, unchanged)", + raw: `{"StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: false, + }, + { + name: "ForAllValues:StringNotEquals denies when any actual value matches", + raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: false, + }, + { + name: "ForAnyValue:StringNotEquals allows when at least one actual value doesn't match", + raw: `{"ForAnyValue:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + { + name: "ForAllValues:StringEquals matches when every actual value is in the set", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + { + name: "ForAllValues:StringEquals fails when one actual value is outside the set", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "manager"}}, + want: false, + }, + { + name: "ForAllValues:StringEquals vacuously matches when the key is entirely absent", + raw: `{"ForAllValues:StringEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "ForAllValues:StringNotEquals vacuously matches when the key is entirely absent", + raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{}, + want: true, + }, + { + name: "ForAnyValue:StringEquals matches when at least one actual value is in the set", + raw: `{"ForAnyValue:StringEquals":{"example.com:groups":"banned"}}`, + ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}}, + want: true, + }, + }) +} + +func TestConditionValuesUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want ConditionValues + wantErr bool + }{ + {"string", `"alice"`, ConditionValues{"alice"}, false}, + {"integer number, unquoted", `5`, ConditionValues{"5"}, false}, + {"decimal number preserves literal text", `5.50`, ConditionValues{"5.50"}, false}, + {"bool true", `true`, ConditionValues{"true"}, false}, + {"bool false", `false`, ConditionValues{"false"}, false}, + {"array of strings", `["a","b"]`, ConditionValues{"a", "b"}, false}, + {"array mixing string/number/bool", `["a",5,true]`, ConditionValues{"a", "5", "true"}, false}, + {"null is rejected", `null`, nil, true}, + {"null array element is rejected", `["a",null]`, nil, true}, + {"nested array element is rejected", `[["a"]]`, nil, true}, + {"object element is rejected", `{"a":"b"}`, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got ConditionValues + err := got.UnmarshalJSON([]byte(tt.json)) + if tt.wantErr { + if err == nil { + t.Fatalf("UnmarshalJSON() error = nil, want non-nil") + } + return + } + if err != nil { + t.Fatalf("UnmarshalJSON() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("UnmarshalJSON() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestParseOperatorName(t *testing.T) { + tests := []struct { + name string + op string + wantOk bool + wantBase string + wantIfExists bool + wantQualif conditionQualifier + }{ + {name: "StringEquals", op: "StringEquals", wantOk: true, wantBase: "StringEquals"}, + {name: "StringEqualsIfExists", op: "StringEqualsIfExists", wantOk: true, wantBase: "StringEquals", wantIfExists: true}, + {name: "NumericGreaterThanEquals", op: "NumericGreaterThanEquals", wantOk: true, wantBase: "NumericGreaterThanEquals"}, + {name: "DateLessThanIfExists", op: "DateLessThanIfExists", wantOk: true, wantBase: "DateLessThan", wantIfExists: true}, + {name: "Bool", op: "Bool", wantOk: true, wantBase: "Bool"}, + {name: "BoolIfExists", op: "BoolIfExists", wantOk: true, wantBase: "Bool", wantIfExists: true}, + {name: "BinaryEquals", op: "BinaryEquals", wantOk: true, wantBase: "BinaryEquals"}, + {name: "ArnLike", op: "ArnLike", wantOk: true, wantBase: "ArnLike"}, + {name: "IpAddress", op: "IpAddress", wantOk: true, wantBase: "IpAddress"}, + {name: "Null", op: "Null", wantOk: true, wantBase: "Null"}, + {name: "ForAllValues:StringEquals", op: "ForAllValues:StringEquals", wantOk: true, wantBase: "StringEquals", wantQualif: qualifierForAllValues}, + {name: "ForAnyValue:StringNotEqualsIfExists", op: "ForAnyValue:StringNotEqualsIfExists", wantOk: true, wantBase: "StringNotEquals", wantIfExists: true, wantQualif: qualifierForAnyValue}, + {name: "ForAllValues:Null accepted, qualifier is a no-op", op: "ForAllValues:Null", wantOk: true, wantBase: "Null", wantQualif: qualifierForAllValues}, + {name: "NullIfExists rejected", op: "NullIfExists", wantOk: false}, + {name: "unrecognized base", op: "FooBarOperator", wantOk: false}, + {name: "unrecognized qualifier prefix left as part of the name", op: "ForSomeValues:StringEquals", wantOk: false}, + {name: "empty string", op: "", wantOk: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseOperatorName(tt.op) + if ok != tt.wantOk { + t.Fatalf("parseOperatorName(%q) ok = %v, want %v", tt.op, ok, tt.wantOk) + } + if !ok { + return + } + if got.base != tt.wantBase || got.ifExists != tt.wantIfExists || got.qualifier != tt.wantQualif { + t.Fatalf("parseOperatorName(%q) = %+v, want {base:%q ifExists:%v qualifier:%v}", tt.op, got, tt.wantBase, tt.wantIfExists, tt.wantQualif) + } + }) + } +} + +func TestGlobMatch(t *testing.T) { + tests := []struct { + pattern, s string + want bool + }{ + {pattern: "user-*", s: "user-123", want: true}, + {pattern: "user-*", s: "admin-123", want: false}, + {pattern: "user-?23", s: "user-123", want: true}, + {pattern: "user-?23", s: "user-1123", want: false}, + {pattern: "*", s: "anything", want: true}, + {pattern: "exact", s: "exact", want: true}, + {pattern: "exact", s: "exacts", want: false}, + } + for _, tt := range tests { + if got := globMatch(tt.pattern, tt.s); got != tt.want { + t.Errorf("globMatch(%q, %q) = %v, want %v", tt.pattern, tt.s, got, tt.want) + } + } +} diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go index 50490de2..31866eba 100644 --- a/iamapi/policy/document.go +++ b/iamapi/policy/document.go @@ -17,6 +17,7 @@ package policy import ( "bytes" "encoding/json" + "fmt" ) // Recognized values for a policy document's Version element. @@ -41,10 +42,7 @@ type Statement struct { NotResource StringOrSlice Principal json.RawMessage NotPrincipal json.RawMessage - // Condition is never structurally validated (neither the identity- nor - // trust-policy path models its grammar) — it is only checked for - // presence, by the trust-policy Cognito-provider rule. - Condition json.RawMessage + Condition json.RawMessage } // UnmarshalJSON accepts Statement as either a single JSON object or an @@ -53,6 +51,17 @@ type Statement struct { // here — Validate reports that as a grammar error so all "empty document" // shapes produce the same message. func (d *Document) UnmarshalJSON(data []byte) error { + // A duplicate key anywhere in the document (top-level Version/Statement, + // a statement's Effect/Action, a Principal key, a nested Condition + // operator or context key, ...) is ambiguous: Go's json package silently + // keeps the last occurrence, but real AWS's policy simulator rejects + // e.g. a duplicated "Effect":"Deny","Effect":"Allow" outright as + // InvalidInput rather than picking one. Reject the whole document + // up front, structurally, rather than special-casing every field. + if err := rejectDuplicateJSONKeys(data); err != nil { + return err + } + var raw struct { Version string Statement json.RawMessage @@ -67,19 +76,99 @@ func (d *Document) UnmarshalJSON(data []byte) error { } var stmts []Statement - if err := json.Unmarshal(raw.Statement, &stmts); err == nil { + if err := unmarshalStrict(raw.Statement, &stmts); err == nil { d.Statement = stmts return nil } var single Statement - if err := json.Unmarshal(raw.Statement, &single); err != nil { + if err := unmarshalStrict(raw.Statement, &single); err != nil { return err } d.Statement = []Statement{single} return nil } +// rejectDuplicateJSONKeys reports an error if any JSON object anywhere in +// raw — at any nesting depth: the top-level document, an individual +// statement, its Principal, or a Condition block's operator/key maps — +// contains the same key twice. The standard decoder accepts this silently +// and keeps the last occurrence, which can turn e.g. a written +// "Effect":"Deny","Effect":"Allow" (rejected by AWS's own policy simulator +// as InvalidInput) into a working Allow instead of a rejected document +func rejectDuplicateJSONKeys(raw []byte) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + tok, err := dec.Token() + if err != nil { + return err + } + return checkDuplicateJSONKeys(dec, tok) +} + +// checkDuplicateJSONKeys recursively walks the value tok (already read from +// dec) for duplicate object keys, consuming the rest of that value's tokens +// from dec — including its closing delimiter, for an object or array — before +// returning. +func checkDuplicateJSONKeys(dec *json.Decoder, tok json.Token) error { + delim, ok := tok.(json.Delim) + if !ok { + return nil // scalar (string/number/bool/null): nothing nested to check + } + + switch delim { + case '{': + seen := make(map[string]struct{}) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key := keyTok.(string) + if _, dup := seen[key]; dup { + return fmt.Errorf("policy: duplicate key %q", key) + } + seen[key] = struct{}{} + + valTok, err := dec.Token() + if err != nil { + return err + } + if err := checkDuplicateJSONKeys(dec, valTok); err != nil { + return err + } + } + _, err := dec.Token() // consume '}' + return err + case '[': + for dec.More() { + valTok, err := dec.Token() + if err != nil { + return err + } + if err := checkDuplicateJSONKeys(dec, valTok); err != nil { + return err + } + } + _, err := dec.Token() // consume ']' + return err + } + return nil +} + +// unmarshalStrict decodes data into v, rejecting any object field that +// doesn't correspond to one of v's exported struct fields - unlike plain +// json.Unmarshal, which silently ignores unrecognized fields. Used for +// Statement specifically, so e.g. a "Conditon" typo is rejected as a +// malformed policy document rather than silently producing an unconditional Allow/Deny +// Statement's field set (Sid/Effect/Action/NotAction/Resource/NotResource/ +// Principal/NotPrincipal/Condition) is AWS's complete statement grammar, so +// nothing legitimate is rejected by this. +func unmarshalStrict(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + // StringOrSlice decodes a JSON value that may be either a single string or // an array of strings, matching the AWS IAM policy grammar for Action, // NotAction, Resource, and NotResource. A JSON-null value decodes to a nil diff --git a/iamapi/policy/document_test.go b/iamapi/policy/document_test.go index bf9437b2..9aaa9d69 100644 --- a/iamapi/policy/document_test.go +++ b/iamapi/policy/document_test.go @@ -111,4 +111,47 @@ func TestDocumentUnmarshalJSON(t *testing.T) { t.Fatal("Unmarshal() error = nil, want non-nil") } }) + + t.Run("unknown field on a statement in an array is rejected", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Conditon":{"StringEquals":{"aws:username":"alice"}}}]}`), &doc) + if err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) + + t.Run("unknown field on a single-object statement is rejected", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Conditon":{"StringEquals":{"aws:username":"alice"}}}}`), &doc) + if err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) + + t.Run("every legitimate statement field at once still succeeds", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Sid":"S1","Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("unknown top-level document field is not rejected", func(t *testing.T) { + // Unlike Statement, Document's outer decode is deliberately not + // strict: real IAM documents can carry a top-level "Id" field this + // codebase doesn't model, and DisallowUnknownFields is recursive so + // it still catches a Statement-level typo without the outer struct + // needing it too. + var doc Document + err := json.Unmarshal([]byte(`{"Id":"some-policy-id","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) } diff --git a/iamapi/policy/identity.go b/iamapi/policy/identity.go new file mode 100644 index 00000000..4ee29dab --- /dev/null +++ b/iamapi/policy/identity.go @@ -0,0 +1,150 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "encoding/json" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/types" +) + +// MaxSessionPolicyBytes is the maximum length, in bytes, of the optional +// inline session policy document AssumeRoleWithWebIdentity's Policy +// parameter accepts, matching AWS's documented quota for that parameter. +const MaxSessionPolicyBytes = 2048 + +// RequestContext carries the request-scoped values an identity-policy +// statement is evaluated against, matching AWS's treatment of authorization +// as a full request-context decision (action, resource, and condition — +// principal is already fixed by which documents are passed in) rather than +// the action name alone. +type RequestContext struct { + // Action is the ":" string being authorized, e.g. + // "iam:GetRole". + Action string + // Resource is the ARN of the specific resource the action targets + // (e.g. a role's own Arn for GetRole, or "*" for an action AWS + // classifies as resource-less, such as a List action). + Resource string + // Condition is the "aws:"-keyed context (aws:SourceIp, + // aws:username, aws:PrincipalArn, aws:userid, ...) a statement's + // Condition block is evaluated against. + Condition map[string][]string +} + +// EvaluateIdentityPolicies reports whether reqCtx is allowed by documents +// (each a user's or role's inline policy entry), using IAM's evaluation +// semantics: a statement must cover the action, the resource, and (if +// present) its Condition block to be considered at all; an explicit Deny +// statement that does so makes the whole evaluation deny regardless of any +// Allow found elsewhere (in the same or another document), and absent an +// explicit deny, at least one covering Allow statement is required — so an +// identity with no matching statement at all is denied by default. +// +// A document that fails to parse, or a statement whose Condition block can't +// be evaluated (see evaluateCondition's ok return), denies the whole +// evaluation rather than being skipped: PutUserPolicy/PutRolePolicy already +// reject any policy document that wouldn't parse or whose Condition uses an +// unrecognized operator, so this only matters for documents written before +// that validation existed - and for exactly that legacy-data case, we can't +// rule out a hidden Deny inside the part we can't evaluate, so the safe +// outcome is to deny rather than silently proceed as if it wasn't there. +func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) bool { + allowed := false + + for _, entry := range documents { + var doc Document + if err := json.Unmarshal([]byte(entry.PolicyDocument), &doc); err != nil { + debuglogger.Logf("identity policy document failed to parse: %v", err) + return false + } + // PutUserPolicy/PutRolePolicy already reject a document that + // wouldn't pass Validate (e.g. both Action and NotAction on one + // statement) at write time, but a document stored before that + // validation existed — or reaching storage through a migration, + // backup restore, or out-of-band write — could still fail it. Assign + // no meaning to a document AWS itself would reject rather than + // evaluating it anyway: re-check it here, at the security boundary, + // not just at ingress. + if err := doc.Validate(); err != nil { + debuglogger.Logf("identity policy document failed validation: %v", err) + return false + } + + for _, stmt := range doc.Statement { + if stmt.Effect != "Allow" && stmt.Effect != "Deny" { + continue + } + if !statementCoversAction(stmt, reqCtx.Action) { + continue + } + if !statementCoversResource(stmt, reqCtx.Resource, reqCtx.Condition, doc.Version) { + continue + } + matched, ok := evaluateCondition(stmt.Condition, reqCtx.Condition, doc.Version) + if !ok { + debuglogger.Logf("identity policy evaluation: statement condition could not be evaluated, denying") + return false + } + if !matched { + continue + } + + if stmt.Effect == "Deny" { + debuglogger.Logf("identity policy evaluation: action %q on resource %q explicitly denied", reqCtx.Action, reqCtx.Resource) + return false + } + allowed = true + } + } + + return allowed +} + +// statementCoversResource reports whether stmt's Resource/NotResource +// authorizes resource. Matching is case-sensitive (unlike action matching): +// ARNs are case-sensitive. version is the enclosing document's Version +// element: each pattern has policy variables (e.g. "${aws:username}") +// substituted from ctxVars before matching only when version is exactly +// Version2012 — AWS documents policy variables as requiring the +// 2012-10-17 policy version; a document with no Version, or the older +// 2008-10-17, matches Resource patterns containing "${...}" as the literal +// text instead, the same as real AWS. A statement with neither Resource nor +// NotResource never matches — Validate already requires every statement to +// carry one, so this only matters for documents written before that +// validation existed. +func statementCoversResource(stmt Statement, resource string, ctxVars map[string][]string, version string) bool { + if len(stmt.Resource) > 0 { + return matchAnyResource(stmt.Resource, resource, ctxVars, version) + } + if len(stmt.NotResource) > 0 { + return !matchAnyResource(stmt.NotResource, resource, ctxVars, version) + } + return false +} + +func matchAnyResource(patterns []string, resource string, ctxVars map[string][]string, version string) bool { + for _, p := range patterns { + pattern := p + if version == Version2012 { + pattern = substitutePolicyVariables(p, ctxVars) + } + if globMatch(pattern, resource) { + return true + } + } + return false +} diff --git a/iamapi/policy/identity_test.go b/iamapi/policy/identity_test.go new file mode 100644 index 00000000..c6b1ff58 --- /dev/null +++ b/iamapi/policy/identity_test.go @@ -0,0 +1,268 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "testing" + + "github.com/versity/versitygw/iamapi/types" +) + +func policyEntries(documents ...string) []types.PolicyEntry { + entries := make([]types.PolicyEntry, len(documents)) + for i, doc := range documents { + entries[i] = types.PolicyEntry{PolicyDocument: doc} + } + return entries +} + +func TestEvaluateIdentityPolicies(t *testing.T) { + tests := []struct { + name string + documents []types.PolicyEntry + reqCtx RequestContext + want bool + }{ + { + name: "no documents denies by default", + documents: nil, + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "no matching statement denies by default", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "matching allow statement allows", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + name: "wildcard action allows", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + name: "explicit deny overrides an allow in another document", + documents: policyEntries( + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`, + `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`, + ), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "explicit deny overrides an allow in the same document", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "action match is case-insensitive", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"IAM:CREATEUSER","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: true, + }, + { + // A malformed document might have contained a Deny we can no + // longer see, so the whole evaluation denies rather than + // silently proceeding as if the document wasn't there. + name: "malformed document denies the whole evaluation, even with a valid Allow elsewhere", + documents: policyEntries(`not json`, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "malformed document denies the whole evaluation regardless of document order", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`, `not json`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + // A Deny guarded by a Condition operator this package doesn't + // recognize (simulating a legacy document stored before + // write-time validation existed - Parse() would reject this + // today) must not be silently skipped in favor of the Allow + // underneath it. + name: "unrecognized operator on a Deny denies, does not let an Allow underneath it win", + documents: policyEntries( + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, + ), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + // Fail-closed on a condition-evaluation error isn't scoped to + // Deny statements specifically - it's a deny-all result for the + // whole evaluation. + name: "unrecognized operator on an Allow-only statement still denies (fail-closed is not Deny-specific)", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + // A document containing any statement Validate() would + // reject (here, an unrelated statement's unrecognized condition + // operator) is invalid as a whole and denies every evaluation + // against it, even a request the offending statement doesn't + // itself cover - assigning no meaning to a document AWS itself + // would reject at write time is safer than evaluating the parts + // of it that happen to look fine. + name: "unrecognized operator in an unrelated statement invalidates the whole document", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:DeleteUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "Null operator end-to-end: denies presence of aws:username", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "Null operator end-to-end: allows when aws:username is absent (session, not user)", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:userid": {"role-id:session"}}}, + want: true, + }, + { + name: "NotAction denies coverage for the excluded action", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"}, + want: false, + }, + { + name: "NotAction allows actions outside the exclusion", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`), + reqCtx: RequestContext{Action: "iam:DeleteUser", Resource: "*"}, + want: true, + }, + { + name: "resource-scoped allow matches the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: true, + }, + { + name: "resource-scoped allow does not cover a different resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: false, + }, + { + name: "resource match is case-sensitive", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/Role-A"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "resource-scoped deny only affects the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: true, + }, + { + name: "resource-scoped deny denies the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "NotResource excludes the named resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"}, + want: false, + }, + { + name: "NotResource allows resources outside the exclusion", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"}, + want: true, + }, + { + // ${aws:username} in Resource must resolve to the requesting + // principal's own name before matching, not be compared as a + // literal string. + name: "policy variable in Resource matches the caller's own resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: true, + }, + { + name: "policy variable in Resource does not match a different principal's resource", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/bob", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "unresolvable policy variable in Resource is left literal and so does not match a real ARN", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice"}, + want: false, + }, + { + // AWS requires Version 2012-10-17 to use policy variables at + // all - the same statement under 2008-10-17 must treat + // "${aws:username}" as literal text, not expand it. + name: "policy variable in Resource is not substituted under version 2008-10-17", + documents: policyEntries(`{"Version":"2008-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "policy variable in Resource is not substituted with no Version at all", + documents: policyEntries(`{"Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`), + reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: false, + }, + { + name: "condition must match", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}}, + want: true, + }, + { + name: "condition mismatch denies by default", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"bob"}}}, + want: false, + }, + { + name: "deny condition must also match to take effect", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}}, + want: true, + }, + { + name: "deny condition matching denies", + documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`), + reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EvaluateIdentityPolicies(tt.documents, tt.reqCtx); got != tt.want { + t.Fatalf("EvaluateIdentityPolicies() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go index c86380f2..9c09a737 100644 --- a/iamapi/policy/trust.go +++ b/iamapi/policy/trust.go @@ -17,7 +17,6 @@ package policy import ( "encoding/json" "fmt" - "slices" "strings" "github.com/versity/versitygw/iamapi/iamerr" @@ -35,6 +34,66 @@ var trustPrincipalKeys = map[string]bool{ const cognitoFederatedProvider = "cognito-identity.amazonaws.com" +// azureSentinelProviderURL is Microsoft Sentinel's registered OIDC provider +// Url (scheme stripped) — a shared provider like the ones in +// sharedOIDCProviderRequiredClaim, but its required identity-provider +// control is not a claim on the token at all: AWS requires the trust +// statement's Condition to scope sts:RoleSessionName (a global STS +// condition key, see policy.go's requestConditionContext and +// webidentity.go's WebIdentityContext.RoleSessionName) instead of a +// ":" key, so it's handled as its own case in +// validateSharedProviderTenancy rather than fitting the shared map. +const azureSentinelProviderURL = "sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d" + +// azureSentinelRequiredKey is the condition key azureSentinelProviderURL's +// trust statements must scope. +const azureSentinelRequiredKey = "sts:RoleSessionName" + +// oidcProviderArnInfix is the fixed separator between the account segment +// and the provider Url in an OIDC provider ARN, matching +// iamutil.BuildOIDCProviderArn's "arn:aws:iam:::oidc-provider/" +// shape (this package can't import iamutil to reuse its ARN parser: iamutil +// already imports policy). +const oidcProviderArnInfix = ":oidc-provider/" + +// sharedOIDCProviderRequiredClaim maps a known shared-audience OIDC issuer's +// hostname (a registered provider's Url, scheme already stripped) to the +// claim suffix a trust statement federating it must scope with a Condition. +// AWS added this requirement for popular CI/CD OIDC issuers because their +// audience is commonly left at a single shared, non-secret default (e.g. +// "sts.amazonaws.com"): unlike a private or self-hosted provider, whose Url +// alone is already tenant-specific, the audience here doesn't distinguish +// one organization's/repo's token from any other's identically-configured +// one, so the trust policy must scope its tenancy claim itself. +// +// Sourced from AWS's own published table of shared OIDC providers and their +// required claims: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc_secure-by-default.html +// Amazon Cognito and Microsoft Sentinel are handled as +// their own special cases in validateSharedProviderTenancy rather than this +// map: Cognito's federated-principal value isn't an OIDC provider ARN at +// all, and Sentinel's required control is a global STS key, not a claim. +// IBM Turbonomic SaaS is a documented shared provider too, but AWS's own +// table declines to give it a fixed Url ("periodically updates their OIDC +// Issuer URL with new versions of the platform") — there is no stable +// hostname to key a map entry on, so it's deliberately omitted here. +var sharedOIDCProviderRequiredClaim = map[string]string{ + "token.actions.githubusercontent.com": "sub", // GitHub Actions + "vstoken.actions.githubusercontent.com": "sub", // GitHub vstoken + "oidc-configuration.audit-log.githubusercontent.com": "sub", // GitHub audit log streaming + "gitlab.com": "sub", // GitLab.com (SaaS) + "agent.buildkite.com": "sub", // Buildkite + "app.terraform.io": "sub", // HCP Terraform / Terraform Cloud + "oidc.codefresh.io": "sub", // Codefresh SaaS + "studio.datachain.ai/api": "sub", // DVC Studio + "scalr.io": "sub", // Scalr + "tokens.cloud.shisho.dev": "sub", // Shisho Cloud + "proidc.upbound.io": "sub", // Upbound + "api.pulumi.com/oidc": "aud", // Pulumi Cloud + "sandboxes.cloud": "aud", // sandboxes.cloud + "oidc.vercel.com": "aud", // Vercel global endpoint +} + // validServicePrincipals are the only Service principal values the gateway // recognizes. Real AWS validates Service against its live catalog of // ~300+ service principals; the gateway only exposes S3, STS, and IAM @@ -114,8 +173,11 @@ func (d Document) ValidateTrust() error { // ValidateTrust checks s against IAM trust-policy statement grammar: a // valid Effect, a required Principal (never NotPrincipal), an Action or -// NotAction with only "sts:"-prefixed values, and no Resource/NotResource. -// Condition is not modeled or validated(not supported at the moment) +// NotAction with only "sts:"-prefixed values, no Resource/NotResource, and - +// if present - a Condition block whose operators are all recognized (see +// conditionShapeValid, shared with the identity-policy side; condition +// *keys* and operand *values* are deliberately not validated here, matching +// AWS behavior). func (s Statement) ValidateTrust() error { switch s.Effect { case "Allow", "Deny": @@ -135,6 +197,19 @@ func (s Statement) ValidateTrust() error { return err } + if !conditionShapeValid(s.Condition) { + return errTrustSyntax + } + + if len(s.Action) > 0 && len(s.NotAction) > 0 { + // Same exclusivity identity policies already enforce (Statement.Validate): + // AWS documents Action and NotAction as mutually exclusive within a + // single statement, and real policy simulation rejects a document + // combining them with InvalidInput - a trust statement isn't + // exempt just because its evaluator (statementCoversAction) happens + // to have well-defined single-field behavior. + return errTrustSyntax + } if len(s.Action) == 0 && len(s.NotAction) == 0 { return errTrustMissingAction } @@ -187,7 +262,6 @@ func (s Statement) validateTrustPrincipal() error { return errTrustEmptyPrincipal } - requiresCondition := false for key, values := range principal { if !trustPrincipalKeys[key] { return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key)) @@ -199,14 +273,137 @@ func (s Statement) validateTrustPrincipal() error { } } } - if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) { - requiresCondition = true + } + + return validateSharedProviderTenancy(s, principal["Federated"]) +} + +// validateSharedProviderTenancy rejects a trust statement that federates a +// known shared-audience provider (Cognito Identity Pools, or a registered +// OIDC provider whose Url is in sharedOIDCProviderRequiredClaim) without a +// Condition that scopes the provider's tenant-identifying claim to a +// specific, non-wildcard value — see sharedOIDCProviderRequiredClaim's +// doc comment for why the audience alone isn't enough for these providers. +// A Federated value that doesn't match either shape (a private/self-hosted +// OIDC provider, or a value too malformed to resolve to a real provider at +// all) imposes no extra requirement here; those are unaffected by this +// check. +func validateSharedProviderTenancy(s Statement, federated []string) error { + for _, v := range federated { + if v == cognitoFederatedProvider { + if !conditionScopesClaim(s.Condition, cognitoFederatedProvider+":aud") { + return errTrustCognitoConditionRequired + } + continue + } + + url, ok := oidcProviderURLFromFederatedArn(v) + if !ok { + continue + } + + if url == azureSentinelProviderURL { + if !conditionScopesClaim(s.Condition, azureSentinelRequiredKey) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf( + "The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, azureSentinelRequiredKey)) + } + continue + } + + claim, known := sharedOIDCProviderRequiredClaim[url] + if !known { + continue + } + key := url + ":" + claim + if !conditionScopesClaim(s.Condition, key) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf( + "The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, key)) } } - - if requiresCondition && len(s.Condition) == 0 { - return errTrustCognitoConditionRequired - } - return nil } + +// oidcProviderURLFromFederatedArn extracts the provider Url from a Federated +// principal ARN shaped like "arn:aws:iam:::oidc-provider/" +// (see iamutil.BuildOIDCProviderArn), reporting ok=false for any value not +// shaped like an OIDC provider ARN at all — a bare federation identifier +// (e.g. "cognito-identity.amazonaws.com") or a malformed value, both handled +// elsewhere (this is deliberately a lightweight shape check, not full ARN +// validation: an actually-malformed ARN is caught later, when the runtime +// AssumeRoleWithWebIdentity path resolves it against real registered +// providers and finds nothing). +func oidcProviderURLFromFederatedArn(value string) (string, bool) { + _, url, ok := strings.Cut(value, oidcProviderArnInfix) + if !ok || url == "" { + return "", false + } + return url, true +} + +// conditionScopesClaim reports whether raw (a statement's Condition block) +// contains a positive String-family comparison (StringEquals, StringLike, or +// StringEqualsIgnoreCase — optionally ForAllValues/ForAnyValue-qualified; +// their Not-negated counterparts don't count, since excluding one value +// doesn't scope to a tenant) against key (matched case-insensitively, same +// as identity-policy condition keys) with at least one value that actually +// scopes the claim. For StringLike specifically — the one operator here +// where '*'/'?' are wildcards, not literal characters — a value consisting +// entirely of wildcard characters (e.g. "*", "**", "?", "*?*") is rejected +// even though it's non-empty: AWS documents that a shared provider's +// tenancy claim "must not consist only of wildcard characters", since +// a pattern with no literal character left after stripping '*'/'?' matches +// every possible value just as completely as a bare "*" does. StringEquals +// and StringEqualsIgnoreCase don't treat '*'/'?' as wildcards at all, so +// only the plain "empty or exactly '*'" check applies to them. A block that +// fails to parse reports false, same as an absent one — +// conditionShapeValid/evaluateCondition are responsible for rejecting or +// fail-closing a block this can't understand; this check only ever adds a +// stricter write-time requirement on top of that. +func conditionScopesClaim(raw json.RawMessage, key string) bool { + if len(raw) == 0 { + return false + } + var block map[string]map[string]ConditionValues + if err := json.Unmarshal(raw, &block); err != nil { + return false + } + for operator, kvs := range block { + op, ok := parseOperatorName(operator) + if !ok { + continue + } + switch op.base { + case "StringEquals", "StringLike", "StringEqualsIgnoreCase": + default: + continue + } + for k, values := range kvs { + if !strings.EqualFold(k, key) { + continue + } + for _, v := range values { + if v == "" || v == "*" { + continue + } + if op.base == "StringLike" && !hasNonWildcardCharacter(v) { + continue + } + return true + } + } + } + return false +} + +// hasNonWildcardCharacter reports whether v contains at least one character +// other than the StringLike wildcards '*' (any run of characters) and '?' +// (any single character) — i.e. whether it scopes to anything narrower than +// "every possible value". +func hasNonWildcardCharacter(v string) bool { + for _, r := range v { + if r != '*' && r != '?' { + return true + } + } + return false +} diff --git a/iamapi/policy/trust_test.go b/iamapi/policy/trust_test.go index ecc51cf6..de888ec2 100644 --- a/iamapi/policy/trust_test.go +++ b/iamapi/policy/trust_test.go @@ -21,10 +21,8 @@ import ( "github.com/versity/versitygw/iamapi/iamerr" ) -// Every case below was verified against a live AWS IAM account, except -// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md). -// The "ec2 service (unsupported)" case is one such deliberate deviation: -// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, +// The "ec2 service (unsupported)" case is a deliberate deviation from real +// AWS: real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, // STS, and IAM APIs, so it restricts Service principals to those three. func TestParseTrust(t *testing.T) { tests := []struct { @@ -62,6 +60,7 @@ func TestParseTrust(t *testing.T) { {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden}, {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction}, + {"both action and notaction rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotAction":"sts:AssumeRoleWithWebIdentity"}]}`, errTrustSyntax}, {"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction}, {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction}, {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction}, @@ -73,6 +72,67 @@ func TestParseTrust(t *testing.T) { {"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired}, {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + // A condition block that doesn't actually scope the required aud + // claim must still be rejected, even though a condition is present. + {"cognito federated with unrelated condition (not aud) is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, errTrustCognitoConditionRequired}, + {"cognito federated with wildcard-only aud is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"*"}}}]}`, errTrustCognitoConditionRequired}, + + // Known shared-audience OIDC CI/CD providers (GitHub Actions, + // GitLab.com, Buildkite, Terraform Cloud) require a Condition scoping + // their "sub" claim, the same way Cognito requires "aud" — their + // audience is commonly left at a single non-secret shared default, so + // it alone doesn't distinguish one tenant's workflow from another's. + {"github actions federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with wildcard-only sub is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"*"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with scoped sub condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"repo:my-org/my-repo:*"}}}]}`, nil}, + {"gitlab federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/gitlab.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "gitlab.com" without a Condition scoping "gitlab.com:sub" to your own tenant.`)}, + + // Wildcard-only patterns must not satisfy a shared provider's + // required scoping - AWS documents that the tenancy claim "must not + // consist only of wildcard characters", not merely "must not be the + // bare string '*'". + {"github actions federated with double-wildcard sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"**"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with single-char-wildcard sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"?"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + {"github actions federated with mixed-wildcard-only sub is still rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{"token.actions.githubusercontent.com:sub":"*?*"}}}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "token.actions.githubusercontent.com" without a Condition scoping "token.actions.githubusercontent.com:sub" to your own tenant.`)}, + // A StringEquals value of literally "**" isn't a wildcard operator + // at all under that operator - it's compared as an exact literal + // string that will never match a real sub claim - so only the + // plain empty/"*" check applies to it, and "**" alone passes that. + {"github actions federated with StringEquals literal double-asterisk is accepted (not a wildcard operator)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"token.actions.githubusercontent.com:sub":"**"}}}]}`, nil}, + + // Additional shared providers from AWS's published table, beyond + // the original four. + {"pulumi federated without aud condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/api.pulumi.com/oidc"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "api.pulumi.com/oidc" without a Condition scoping "api.pulumi.com/oidc:aud" to your own tenant.`)}, + {"pulumi federated with scoped aud condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/api.pulumi.com/oidc"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"api.pulumi.com/oidc:aud":"my-org"}}}]}`, nil}, + {"vercel federated without aud condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/oidc.vercel.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "oidc.vercel.com" without a Condition scoping "oidc.vercel.com:aud" to your own tenant.`)}, + {"upbound federated without sub condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/proidc.upbound.io"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "proidc.upbound.io" without a Condition scoping "proidc.upbound.io:sub" to your own tenant.`)}, + + // Microsoft Sentinel is a shared provider whose required control is + // the global sts:RoleSessionName key, not a claim on the token - a + // non-claim control distinct from every other entry here. + {"azure sentinel federated without RoleSessionName condition is rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, iamerr.MalformedPolicyDocument(`The trust policy trusts shared OpenID Connect provider "sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d" without a Condition scoping "sts:RoleSessionName" to your own tenant.`)}, + {"azure sentinel federated with scoped RoleSessionName condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"sts:RoleSessionName":"my-workspace"}}}]}`, nil}, + + // A private/self-hosted OIDC provider (not in the shared-provider + // table) imposes no extra Condition requirement - its Url is already + // tenant-specific, unlike the shared community providers above. + {"private oidc provider federated without condition is accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/idp.my-company.example.com"},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, nil}, + + {"valid condition, Null", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Null":{"aws:username":"true"}}}]}`, nil}, + {"valid condition, Bool", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, nil}, + {"valid condition, NumericEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"NumericEquals":{"example.com:level":"5"}}}]}`, nil}, + {"valid condition, ArnLike", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}}]}`, nil}, + {"valid condition, ForAllValues-qualified operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"ForAllValues:StringEquals":{"aws:TagKeys":["a","b"]}}}]}`, nil}, + + {"invalid condition, unrecognized operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, errTrustSyntax}, + {"invalid condition, malformed shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":"not an object"}]}`, errTrustSyntax}, + + // A misspelled Statement field (as opposed to an unrecognized + // Condition operator) is caught earlier, inside + // Document.UnmarshalJSON's strict Statement decoding - reached + // through ParseTrust's own top-level json.Unmarshal - so it + // surfaces as errTrustInvalidJSON, not errTrustSyntax. + {"misspelled Condition field is rejected, not silently ignored", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Conditon":{"StringEquals":{"aws:username":"alice"}}}]}`, errTrustInvalidJSON}, } for _, tt := range tests { diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go index 8e14b06b..3987897b 100644 --- a/iamapi/policy/validate.go +++ b/iamapi/policy/validate.go @@ -120,8 +120,10 @@ func (d Document) Validate() error { // Validate checks s against IAM policy statement grammar: a valid Effect, // no Principal/NotPrincipal, an Action or NotAction (not both) with -// vendor-prefixed values, and a Resource or NotResource (not both) with -// ARN-shaped values. Condition is not modeled or validated. +// vendor-prefixed values, a Resource or NotResource (not both) with +// ARN-shaped values, and - if present - a Condition block whose operators +// are all recognized (see conditionShapeValid; condition *keys* and operand +// *values* are deliberately not validated here, matching AWS behavior). func (s Statement) Validate() error { switch s.Effect { case "Allow", "Deny": @@ -133,6 +135,10 @@ func (s Statement) Validate() error { return errPrincipalNotAllowed } + if !conditionShapeValid(s.Condition) { + return errSyntax + } + if len(s.Action) > 0 && len(s.NotAction) > 0 { return errSyntax } diff --git a/iamapi/policy/validate_test.go b/iamapi/policy/validate_test.go index 8019a6ee..3175d2e6 100644 --- a/iamapi/policy/validate_test.go +++ b/iamapi/policy/validate_test.go @@ -22,7 +22,6 @@ import ( "github.com/versity/versitygw/iamapi/iamerr" ) -// Every case below was verified against a live AWS IAM account. func TestValidate(t *testing.T) { tests := []struct { name string @@ -39,6 +38,19 @@ func TestValidate(t *testing.T) { {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil}, {"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil}, + {"valid condition, StringEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`, nil}, + {"valid condition, Null", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"Null":{"aws:username":"true"}}}]}`, nil}, + {"valid condition, Bool", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}`, nil}, + {"valid condition, NumericEquals", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"NumericEquals":{"s3:max-keys":"5"}}}]}`, nil}, + {"valid condition, ArnLike", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}}]}`, nil}, + {"valid condition, ForAllValues-qualified operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"ForAllValues:StringEquals":{"aws:TagKeys":["a","b"]}}}]}`, nil}, + {"valid condition, recognized operator with an unmodeled key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"StringEquals":{"aws:SomeRandomKey":"x"}}}]}`, nil}, + {"valid condition, empty object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{}}]}`, nil}, + + {"invalid condition, unrecognized operator", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`, errSyntax}, + {"invalid condition, malformed shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":"not an object"}]}`, errSyntax}, + {"invalid condition, NullIfExists", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"NullIfExists":{"aws:username":"true"}}}]}`, errSyntax}, + {"invalid json syntax", `{invalid json`, errSyntax}, {"empty object", `{}`, errSyntax}, {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, diff --git a/iamapi/policy/webidentity.go b/iamapi/policy/webidentity.go new file mode 100644 index 00000000..954acb93 --- /dev/null +++ b/iamapi/policy/webidentity.go @@ -0,0 +1,303 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/versity/versitygw/debuglogger" +) + +// AssumeRoleWithWebIdentityAction is the sts action name role trust +// statements must (directly, or via a wildcard) authorize for +// AssumeRoleWithWebIdentity to succeed. +const AssumeRoleWithWebIdentityAction = "sts:AssumeRoleWithWebIdentity" + +// WebIdentityMatch is the outcome of evaluating a role's trust policy +// against an authenticated web identity federation attempt. The distinct +// NoPrincipal/NoIssuerMatch/ConditionFailed cases exist because AWS reports +// two different errors depending on which one occurs: NoPrincipal (no +// Federated principal in the trust policy resolves to a provider that +// actually exists) is reported as AccessDenied identically to a +// nonexistent role, while NoIssuerMatch and ConditionFailed (an existing, +// referenced provider's signing keys and claims were checked and didn't +// satisfy the request) are both reported as InvalidIdentityToken. +type WebIdentityMatch int + +const ( + NoPrincipal WebIdentityMatch = iota + NoIssuerMatch + ConditionFailed + ExplicitlyDenied + Allowed +) + +// ProviderLookup resolves a Federated principal ARN to the scheme-stripped +// Url of the OIDC provider it names, reporting ok=false for any ARN that +// doesn't correspond to a provider that actually exists. +type ProviderLookup func(federatedArn string) (url string, ok bool) + +// WebIdentityContext carries the token values needed to evaluate a trust +// statement's Condition block, keyed the way AWS's own condition context +// keys are: ":". +type WebIdentityContext struct { + ProviderURL string + // Audience is the token's effective audience: azp when present, + // otherwise the token's single aud value. Mapped to :aud. + Audience string + // OriginalAudience is the token's actual aud claim value(s), only ever + // set when azp is present (and therefore differs from Audience) — + // mapped to :oaud. This matters for Google hybrid + // clients, where aud names the backend project and azp names the + // actual OAuth client that requested the token. + OriginalAudience []string + Subject string + // Claims holds every other top-level string/string-array claim from + // the token, for Condition keys beyond aud/sub (e.g. a custom "amr" + // or "groups" claim). Values are pre-normalized to []string. + Claims map[string][]string + + // The remaining fields are request-scoped, not token-scoped: unlike + // Claims/Audience/Subject (all read from the presented JWT), these carry + // the same global request facts identity-policy Condition evaluation + // already sees (iammiddleware.requestConditionContext) so a trust + // statement's explicit Deny can be scoped by them too - a + // broad-Allow-plus-Deny trust policy must see the same request facts an + // Allow does, not treat the key as always absent. + + // SourceIP is the caller's address, mapped to aws:SourceIp. + SourceIP string + // Secure is whether the connection is TLS, mapped to + // aws:SecureTransport - AWS documents this key as present on every + // request, not just TLS ones. + Secure bool + // Now is the request's evaluation time, mapped to aws:CurrentTime and + // aws:EpochTime. + Now time.Time + // RoleSessionName is the caller-supplied RoleSessionName parameter, + // mapped to sts:RoleSessionName. + RoleSessionName string +} + +// conditionContext builds the map a trust statement's Condition block is +// evaluated against: ":" keys from the token itself, +// plus the request-scoped global keys identity-policy evaluation already +// exposes — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, +// aws:EpochTime, and sts:RoleSessionName — so an explicit Deny conditioned +// on any of these sees the same facts an Allow would. +func (w WebIdentityContext) conditionContext() map[string][]string { + ctxVars := make(map[string][]string, len(w.Claims)+8) + for claim, values := range w.Claims { + ctxVars[w.ProviderURL+":"+claim] = values + } + if w.Audience != "" { + ctxVars[w.ProviderURL+":aud"] = []string{w.Audience} + } + if len(w.OriginalAudience) > 0 { + ctxVars[w.ProviderURL+":oaud"] = w.OriginalAudience + } + if w.Subject != "" { + ctxVars[w.ProviderURL+":sub"] = []string{w.Subject} + } + if w.SourceIP != "" { + ctxVars["aws:SourceIp"] = []string{w.SourceIP} + } + ctxVars["aws:SecureTransport"] = []string{strconv.FormatBool(w.Secure)} + if !w.Now.IsZero() { + ctxVars["aws:CurrentTime"] = []string{w.Now.Format(time.RFC3339)} + ctxVars["aws:EpochTime"] = []string{strconv.FormatInt(w.Now.Unix(), 10)} + } + if w.RoleSessionName != "" { + ctxVars["sts:RoleSessionName"] = []string{w.RoleSessionName} + } + return ctxVars +} + +// EvaluateWebIdentityTrust evaluates document (a role's +// AssumeRolePolicyDocument) against wctx, resolving each statement's +// Federated principal(s) via lookup. +// +// The evaluation order mirrors AWS's observed behavior: first, whether any +// statement's Federated principal resolves to a provider that actually +// exists (regardless of whether its Url matches the token) determines +// NoPrincipal vs the later cases; only among statements whose provider +// exists AND whose Url matches wctx.ProviderURL does the token's Condition +// get evaluated. An explicit Deny statement matching the same provider, +// action and condition overrides an otherwise-matching Allow. +func EvaluateWebIdentityTrust(document string, lookup ProviderLookup, wctx WebIdentityContext) (WebIdentityMatch, string) { + var doc Document + if err := json.Unmarshal([]byte(document), &doc); err != nil { + debuglogger.Logf("role trust policy document failed to parse: %v", err) + return NoPrincipal, "" + } + // CreateRole/UpdateAssumeRolePolicy already reject a trust document that + // wouldn't pass ValidateTrust at write time, but a document stored + // before that validation existed could still fail it. Assign no meaning + // to a document AWS itself would reject — NoPrincipal is the same safe + // default an unresolvable Federated principal produces, reported as + // AccessDenied identically to a nonexistent role. + if err := doc.ValidateTrust(); err != nil { + debuglogger.Logf("role trust policy document failed validation: %v", err) + return NoPrincipal, "" + } + + ctxVars := wctx.conditionContext() + + anyExistingPrincipal := false + anyIssuerMatch := false + var allowedProviderArn string + allowed := false + denied := false + + for _, stmt := range doc.Statement { + if stmt.Effect != "Allow" && stmt.Effect != "Deny" { + continue + } + if !statementCoversAction(stmt, AssumeRoleWithWebIdentityAction) { + continue + } + + for _, federatedArn := range federatedPrincipals(stmt.Principal) { + url, ok := lookup(federatedArn) + if !ok { + continue + } + anyExistingPrincipal = true + if url != wctx.ProviderURL { + continue + } + anyIssuerMatch = true + + matched, condOk := evaluateCondition(stmt.Condition, ctxVars, doc.Version) + if !condOk { + debuglogger.Logf("web identity trust evaluation: statement condition could not be evaluated, denying") + denied = true + continue + } + if !matched { + continue + } + + if stmt.Effect == "Deny" { + denied = true + continue + } + allowed = true + allowedProviderArn = federatedArn + } + } + + switch { + case denied: + debuglogger.Logf("web identity trust evaluation: explicitly denied by trust policy") + return ExplicitlyDenied, "" + case allowed: + return Allowed, allowedProviderArn + case anyIssuerMatch: + debuglogger.Logf("web identity trust evaluation: provider %q matched but condition block did not", wctx.ProviderURL) + return ConditionFailed, "" + case anyExistingPrincipal: + debuglogger.Logf("web identity trust evaluation: no trust statement's provider matches issuer %q", wctx.ProviderURL) + return NoIssuerMatch, "" + default: + debuglogger.Logf("web identity trust evaluation: no trust statement resolves to an existing provider") + return NoPrincipal, "" + } +} + +// federatedPrincipals extracts a statement's Principal.Federated value(s), +// tolerating both a bare string and an array (empty/absent on any parse +// failure, since a statement whose Principal doesn't parse simply matches +// nothing here — CreateRole/UpdateAssumeRolePolicy already reject any +// trust policy that wouldn't parse this way). +func federatedPrincipals(raw json.RawMessage) []string { + if len(raw) == 0 { + return nil + } + var principal map[string]StringOrSlice + if err := json.Unmarshal(raw, &principal); err != nil { + return nil + } + return principal["Federated"] +} + +// statementCoversAction reports whether stmt's Action/NotAction authorizes +// action. +func statementCoversAction(stmt Statement, action string) bool { + if len(stmt.Action) > 0 { + return matchAny(stmt.Action, action) + } + if len(stmt.NotAction) > 0 { + return !matchAny(stmt.NotAction, action) + } + return false +} + +func matchAny(patterns []string, action string) bool { + for _, p := range patterns { + if matchActionPattern(p, action) { + return true + } + } + return false +} + +// matchActionPattern matches action against pattern, a case-insensitive +// IAM-style glob ('*' any run of characters, '?' any single character) — +// e.g. "sts:*" or "sts:AssumeRole*" both match "sts:AssumeRoleWithWebIdentity". +func matchActionPattern(pattern, action string) bool { + return globMatch(toLowerASCII(pattern), toLowerASCII(action)) +} + +func toLowerASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// globMatch implements the small wildcard grammar IAM Action/Resource +// patterns use: '*' matches any run of characters (including none), '?' +// matches exactly one character, everything else matches literally. +func globMatch(pattern, s string) bool { + var pi, si, star, match int + star = -1 + for si < len(s) { + switch { + case pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == s[si]): + pi++ + si++ + case pi < len(pattern) && pattern[pi] == '*': + star = pi + match = si + pi++ + case star != -1: + pi = star + 1 + match++ + si = match + default: + return false + } + } + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + return pi == len(pattern) +} diff --git a/iamapi/policy/webidentity_test.go b/iamapi/policy/webidentity_test.go new file mode 100644 index 00000000..46aa2290 --- /dev/null +++ b/iamapi/policy/webidentity_test.go @@ -0,0 +1,296 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package policy + +import "testing" + +const testProviderArn = "arn:aws:iam::000000000000:oidc-provider/example.com" +const otherProviderArn = "arn:aws:iam::000000000000:oidc-provider/other.com" + +// existingProviders resolves testProviderArn -> "example.com" and +// otherProviderArn -> "other.com"; any other ARN reports not-found, +// modeling a dangling trust-policy reference to a provider that was never +// created (or has since been deleted). +func existingProviders(arn string) (string, bool) { + switch arn { + case testProviderArn: + return "example.com", true + case otherProviderArn: + return "other.com", true + default: + return "", false + } +} + +func TestEvaluateWebIdentityTrust(t *testing.T) { + tests := []struct { + name string + document string + wctx WebIdentityContext + wantResult WebIdentityMatch + wantArn string + }{ + { + name: "simple allow, no condition", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "wildcard action matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:*"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "action does not match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRole"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + name: "dangling federated reference to a provider that doesn't exist", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"arn:aws:iam::000000000000:oidc-provider/never-created.example.com"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + name: "existing provider referenced but issuer doesn't match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"}]}`, + wctx: WebIdentityContext{ProviderURL: "unregistered.example.com"}, + wantResult: NoIssuerMatch, + }, + { + name: "condition matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "client1"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "condition does not match", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "wrong-client"}, + wantResult: ConditionFailed, + }, + { + name: "explicit deny overrides matching allow", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Deny","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: ExplicitlyDenied, + }, + { + name: "deny for a different provider does not affect allow for this one", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Deny","Principal":{"Federated":"` + otherProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "second statement matches when first references a different provider", + document: `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"Federated":"` + otherProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"}, + {"Effect":"Allow","Principal":{"Federated":"` + testProviderArn + `"},"Action":"sts:AssumeRoleWithWebIdentity"} + ]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "malformed document", + document: `not json`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: NoPrincipal, + }, + { + // A Condition operator this package doesn't recognize (simulating + // a legacy document stored before write-time validation existed) + // must deny rather than being silently skipped or evaluated. The + // ValidateTrust re-check catches this before per-statement + // evaluation even runs, reported as NoPrincipal - the same + // "assign no meaning to an invalid document" outcome as an + // unresolvable Federated principal, and mapped to the identical + // AccessDenied response as ExplicitlyDenied by the controller. + name: "unrecognized operator on a matching statement denies", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"FooBarOperator":{"example.com:aud":"client1"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "client1"}, + wantResult: NoPrincipal, + }, + { + // Claims are genuinely multivalued in production (a token can + // carry a "groups": ["admin","banned"] claim), unlike + // RequestContext.Condition on the identity-policy side - this + // is the most realistic place to exercise the multivalue + // aggregation semantics documented on aggregate() in + // condition.go. "banned" is present among the claim's values, + // so unqualified StringNotEquals (pre-existing, unchanged + // semantics: fails to match if any actual value matches) fails + // to match, and the Allow's condition doesn't hold. + name: "StringNotEquals against a genuinely multivalued claim doesn't match when any value matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringNotEquals":{"example.com:groups":"banned"}}}]}`, + wctx: WebIdentityContext{ + ProviderURL: "example.com", + Claims: map[string][]string{"groups": {"admin", "banned"}}, + }, + wantResult: ConditionFailed, + }, + { + name: "Null operator against a claim that's present", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Null":{"example.com:amr":"false"}}}]}`, + wctx: WebIdentityContext{ + ProviderURL: "example.com", + Claims: map[string][]string{"amr": {"mfa"}}, + }, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "Null operator against a claim that's absent", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Null":{"example.com:amr":"false"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com"}, + wantResult: ConditionFailed, + }, + // A broad Allow plus an explicit Deny scoped to a global request key + // (aws:SourceIp, aws:SecureTransport, sts:RoleSessionName) must see + // the same request facts an Allow would, so a Deny relying on any + // of them overrides the broad Allow. + { + name: "Deny on aws:SourceIp applies when the caller's address matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"IpAddress":{"aws:SourceIp":"203.0.113.0/24"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", SourceIP: "203.0.113.5"}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on aws:SourceIp does not apply for a different address", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"IpAddress":{"aws:SourceIp":"203.0.113.0/24"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", SourceIP: "198.51.100.5"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + { + name: "Deny on aws:SecureTransport=false applies to a plaintext request", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", Secure: false}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on sts:RoleSessionName applies when it matches", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"sts:RoleSessionName":"forbidden-session"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", RoleSessionName: "forbidden-session"}, + wantResult: ExplicitlyDenied, + }, + { + name: "Deny on sts:RoleSessionName does not apply for a different session name", + document: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity"},{"Effect":"Deny", + "Principal":{"Federated":"` + testProviderArn + `"}, + "Action":"sts:AssumeRoleWithWebIdentity", + "Condition":{"StringEquals":{"sts:RoleSessionName":"forbidden-session"}}}]}`, + wctx: WebIdentityContext{ProviderURL: "example.com", Audience: "aud1", RoleSessionName: "allowed-session"}, + wantResult: Allowed, + wantArn: testProviderArn, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, arn := EvaluateWebIdentityTrust(tt.document, existingProviders, tt.wctx) + if result != tt.wantResult { + t.Errorf("result = %v, want %v", result, tt.wantResult) + } + if arn != tt.wantArn { + t.Errorf("providerArn = %q, want %q", arn, tt.wantArn) + } + }) + } +} + +func TestMatchActionPattern(t *testing.T) { + tests := []struct { + pattern string + action string + want bool + }{ + {pattern: "sts:AssumeRoleWithWebIdentity", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:*", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:AssumeRole*", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "STS:ASSUMEROLEWITHWEBIDENTITY", action: "sts:AssumeRoleWithWebIdentity", want: true}, + {pattern: "sts:AssumeRole", action: "sts:AssumeRoleWithWebIdentity", want: false}, + {pattern: "iam:*", action: "sts:AssumeRoleWithWebIdentity", want: false}, + {pattern: "sts:AssumeRoleWithWebIdentit?", action: "sts:AssumeRoleWithWebIdentity", want: true}, + } + for _, tt := range tests { + if got := matchActionPattern(tt.pattern, tt.action); got != tt.want { + t.Errorf("matchActionPattern(%q, %q) = %v, want %v", tt.pattern, tt.action, got, tt.want) + } + } +} diff --git a/iamapi/response.go b/iamapi/response.go index ee799e09..0d4cf8b6 100644 --- a/iamapi/response.go +++ b/iamapi/response.go @@ -22,6 +22,7 @@ import ( "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iammiddleware" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" "github.com/versity/versitygw/internal/httpctx" ) @@ -75,11 +76,17 @@ func ProcessController(ctx fiber.Ctx, controller ActionHandler) error { ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML) if apiErr, ok := err.(iamerr.APIError); ok { + if isSTSAction(ctx) { + apiErr = iamerr.WithNamespace(apiErr, iamerr.STSNamespace).(iamerr.APIError) + } return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID)) } debuglogger.InternalError(err) internalErr := iamerr.GetAPIError(iamerr.ErrInternalFailure) + if isSTSAction(ctx) { + internalErr.XMLNamespace = iamerr.STSNamespace + } return ctx.Status(internalErr.StatusCode()).Send(internalErr.XMLBody(requestID)) } @@ -121,6 +128,15 @@ func ProcessController(ctx fiber.Ctx, controller ActionHandler) error { return ctx.Status(status).Send(res) } +// isSTSAction reports whether the current request's Action is one of the +// STS actions sharing this IAM endpoint (see router.go's stsActions), +// which render both success and error responses under STS's own XML +// namespace rather than IAM's. +func isSTSAction(ctx fiber.Ctx) bool { + action, _ := iamutil.RequestParam(ctx, "Action") + return stsActions[action] +} + func SetResponseHeaders(ctx fiber.Ctx, headers map[string]*string) { if headers == nil { return diff --git a/iamapi/router.go b/iamapi/router.go index 430398fb..6820cd1c 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -22,14 +22,26 @@ import ( "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/internal/sigv4auth" ) const ( - iamAPIVersion = "2010-05-08" - noVersionSpecified = "NO_VERSION_SPECIFIED" - productURL = "https://www.versity.com/products/versitygw/" + iamAPIVersion = "2010-05-08" + stsAPIVersion = "2011-06-15" + noVersionSpecified = "NO_VERSION_SPECIFIED" + productURL = "https://www.versity.com/products/versitygw/" + actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity" ) +// stsActions are routed through this same IAM endpoint but, being real STS +// actions, are versioned against stsAPIVersion rather than iamAPIVersion — +// and (see response.go's ProcessController) render under STS's own XML +// namespace rather than IAM's. +var stsActions = map[string]bool{ + "AssumeRoleWithWebIdentity": true, + "GetCallerIdentity": true, +} + var unknownOperationBody = []byte("\n") type IAMApiRouter struct { @@ -83,11 +95,34 @@ func (r *IAMApiRouter) Init() { "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, + // STS actions (routed through this same endpoint; see stsActions) + "AssumeRoleWithWebIdentity": r.Ctrl.AssumeRoleWithWebIdentity, + "GetCallerIdentity": r.Ctrl.GetCallerIdentity, } - actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) - r.app.Get("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute) - r.app.Post("/*", iamutil.MatchQueryOrFormArgs("Action"), actionRoute) + iamRoute := ProcessHandlers(r.routeAction, + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceIAM, r.rootCreds, r.store), + iammiddleware.VerifyIAMPolicy(r.store), + ) + stsAuthRoute := ProcessHandlers(r.routeAction, + iammiddleware.VerifyIAMAuth(sigv4auth.ServiceSTS, r.rootCreds, r.store), + ) + stsOpenRoute := ProcessHandlers(r.routeAction) + + dispatch := func(ctx fiber.Ctx) error { + action, _ := iamutil.RequestParam(ctx, "Action") + switch { + case action == actionAssumeRoleWithWebIdentity: + return stsOpenRoute(ctx) + case stsActions[action]: + return stsAuthRoute(ctx) + default: + return iamRoute(ctx) + } + } + + r.app.Get("/*", iamutil.MatchQueryOrFormArgs("Action"), dispatch) + r.app.Post("/*", iamutil.MatchQueryOrFormArgs("Action"), dispatch) r.app.All("/", r.redirectRoot) r.app.All("*", r.unknownOperation) @@ -99,7 +134,12 @@ func (r *IAMApiRouter) routeAction(ctx fiber.Ctx) (*Response, error) { if !versionSpecified { version = noVersionSpecified } - if version != iamAPIVersion { + + expectedVersion := iamAPIVersion + if stsActions[action] { + expectedVersion = stsAPIVersion + } + if version != expectedVersion { return &Response{}, iamerr.InvalidAction(action, version) } diff --git a/iamapi/server.go b/iamapi/server.go index 43660610..43d09a2c 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -102,6 +102,9 @@ func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | vgw-iam | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 70cc7ac1..27acfc3a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -69,6 +69,11 @@ type iamConfig struct { // stripped, exactly as given at creation — no index needed since // lookup is by exact string, not a case-insensitive human name). OIDCProviders map[string]types.OIDCProvider `json:"oidcProviders"` + + // Sessions is keyed by AccessKeyId. Entries whose Expiration has + // passed are pruned opportunistically whenever a new session is + // created (see pruneExpiredSessions), rather than on a timer. + Sessions map[string]types.Session `json:"sessions"` } func defaultIAMConfig() iamConfig { @@ -79,6 +84,7 @@ func defaultIAMConfig() iamConfig { Roles: map[string]types.Role{}, RoleNameIndex: map[string]string{}, OIDCProviders: map[string]types.OIDCProvider{}, + Sessions: map[string]types.Session{}, } } @@ -115,6 +121,10 @@ func normalizeIAMConfig(conf *iamConfig) { if conf.OIDCProviders == nil { conf.OIDCProviders = make(map[string]types.OIDCProvider) } + + if conf.Sessions == nil { + conf.Sessions = make(map[string]types.Session) + } } // lookupUser resolves name to the canonical stored user name and entry, @@ -213,6 +223,26 @@ func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User return cloneUser(user), nil } +func (s *InternalStore) GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) { + s.RLock() + conf, err := s.engine.GetIAM() + if err != nil { + s.RUnlock() + return nil, err + } + username, ok := conf.AccessKeyIndex[accessKeyID] + s.RUnlock() + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + return user, nil +} + func (s *InternalStore) ListUsers(_ context.Context, input ListUsersInput) (*ListUsersOutput, error) { s.RLock() defer s.RUnlock() @@ -464,6 +494,45 @@ func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID stri return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } +func (s *InternalStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + username, ok := conf.AccessKeyIndex[accessKeyID] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user, ok := conf.Users[username] + if !ok { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + found := false + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + user.AccessKeys[i].LastUsedDate = when + user.AccessKeys[i].LastUsedService = service + user.AccessKeys[i].LastUsedRegion = region + found = true + break + } + } + if !found { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + + conf.Users[username] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { s.RLock() defer s.RUnlock() @@ -1166,6 +1235,72 @@ func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn stri return unwrapAPIError(err) } +func (s *InternalStore) CreateSession(_ context.Context, session types.Session) (*types.Session, error) { + s.Lock() + defer s.Unlock() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + pruneExpiredSessions(conf, session.CreateDate) + if activeSessionCountForRole(conf, session.RoleArn) >= MaxActiveSessionsPerRole { + return nil, iamerr.GetAPIError(iamerr.ErrThrottling) + } + conf.Sessions[session.AccessKeyId] = session + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + cloned := session + return &cloned, nil +} + +// activeSessionCountForRole counts conf's sessions belonging to roleArn. +// Called after pruneExpiredSessions, so this only ever counts sessions that +// are still actually active. +func activeSessionCountForRole(conf iamConfig, roleArn string) int { + count := 0 + for _, sess := range conf.Sessions { + if sess.RoleArn == roleArn { + count++ + } + } + return count +} + +func (s *InternalStore) GetSession(_ context.Context, accessKeyID string) (*types.Session, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + session, ok := conf.Sessions[accessKeyID] + if !ok || !session.Expiration.After(time.Now().UTC()) { + return nil, ErrSessionNotFound + } + + cloned := session + return &cloned, nil +} + +// pruneExpiredSessions removes every session whose Expiration is at or +// before now. Called from CreateSession so the sessions map never grows +// unbounded, without needing a separate timer/goroutine. +func pruneExpiredSessions(conf iamConfig, now time.Time) { + for accessKeyID, session := range conf.Sessions { + if !session.Expiration.After(now) { + delete(conf.Sessions, accessKeyID) + } + } +} + func cloneOIDCProvider(p types.OIDCProvider) *types.OIDCProvider { cloned := p cloned.ClientIDList = slices.Clone(p.ClientIDList) diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 7f0b9a73..28aa5a8d 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -45,10 +45,27 @@ const MaxClientIDsPerOIDCProvider = 100 // single account may hold const MaxOIDCProvidersPerAccount = 100 +// MaxActiveSessionsPerRole bounds how many currently-unexpired +// AssumeRoleWithWebIdentity sessions a single role may have at once. +// AWS manages and rate-limits STS as a hosted service with no +// customer-visible equivalent quota to match for fidelity; this exists +// purely as local resource protection, since without it a single valid +// federated token can be replayed indefinitely to grow the session +// store — every InternalStore rewrite, or Vault KV path/metadata entry — +// without bound. Chosen generously enough to not constrain any legitimate +// workload's concurrent session count. +// +// A var, not a const, so tests can temporarily lower it rather than paying +// the cost of actually creating 1000 sessions to exercise the cap. +var MaxActiveSessionsPerRole = 1000 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") + // ErrSessionNotFound is returned by GetSession when accessKeyID names no + // session, or names one whose Expiration has already passed. + ErrSessionNotFound = errors.New("iamapi: session not found") ) type ListUsersInput struct { @@ -165,6 +182,7 @@ type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) DeleteUser(ctx context.Context, username string) error GetUser(ctx context.Context, username string) (*types.User, error) + GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) @@ -173,6 +191,12 @@ type Storer interface { DeleteAccessKey(ctx context.Context, username, accessKeyID string) error GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) + // RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed + // metadata (service, region, and timestamp) to reflect a successful + // authentication at when. Called best-effort/asynchronously by the auth + // middleware, so implementations should treat a lost update under + // concurrent use as acceptable rather than something worth retrying hard. + RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) @@ -198,6 +222,9 @@ type Storer interface { AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error + + CreateSession(ctx context.Context, session types.Session) (*types.Session, error) + GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index 59c95a56..cbf32af4 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -17,6 +17,7 @@ package storage import ( "context" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -93,7 +94,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { { Path: "/engineering/", UserName: "alice", - UserID: "AIDA22222222222222222", + UserID: "AIDAx2222222222222222", Arn: "arn:aws:iam::000000000000:user/engineering/alice", CreateDate: created, Tags: []types.Tag{ @@ -104,14 +105,14 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { { Path: "/engineering/platform/", UserName: "bob", - UserID: "AIDA33333333333333333", + UserID: "AIDAx3333333333333333", Arn: "arn:aws:iam::000000000000:user/engineering/platform/bob", CreateDate: created.Add(time.Second), }, { Path: "/ops/", UserName: "carol", - UserID: "AIDA44444444444444444", + UserID: "AIDAx4444444444444444", Arn: "arn:aws:iam::000000000000:user/ops/carol", CreateDate: created.Add(2 * time.Second), }, @@ -200,7 +201,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { if _, err := reopened.CreateAccessKey(ctx, CreateAccessKeyInput{ UserName: "zoe", - AccessKeyID: "AKIAZZZZZZZZZZZZZZZZ", + AccessKeyID: "AKIAzZZZZZZZZZZZZZZZ", SecretAccessKey: "secret", Status: "Active", CreateDate: created, @@ -210,7 +211,7 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflict)) { t.Fatalf("DeleteUser with access keys err = %v, want DeleteConflict", err) } - if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAZZZZZZZZZZZZZZZZ"); err != nil { + if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAzZZZZZZZZZZZZZZZ"); err != nil { t.Fatalf("DeleteAccessKey: %v", err) } @@ -222,6 +223,39 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { } } +func TestInternalStoreGetUserByAccessKeyID(t *testing.T) { + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateAccessKey(ctx, CreateAccessKeyInput{ + UserName: "alice", + AccessKeyID: "AKIAALICE0000000000", + SecretAccessKey: "secret", + Status: "Active", + CreateDate: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + got, err := store.GetUserByAccessKeyID(ctx, "AKIAALICE0000000000") + if err != nil { + t.Fatalf("GetUserByAccessKeyID: %v", err) + } + if got.UserName != "alice" { + t.Fatalf("GetUserByAccessKeyID = %#v, want alice", got) + } + + if _, err := store.GetUserByAccessKeyID(ctx, "AKIAuNKNOWN0000000000"); !errors.Is(err, iamerr.NoSuchEntityAccessKey("AKIAuNKNOWN0000000000")) { + t.Fatalf("GetUserByAccessKeyID unknown key err = %v, want NoSuchEntityAccessKey", err) + } +} + func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { ctx := context.Background() store, err := NewInternal(t.TempDir()) @@ -229,10 +263,10 @@ func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { t.Fatalf("NewInternal: %v", err) } - if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDA11111111111111111"}); err != nil { + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil { t.Fatalf("CreateUser: %v", err) } - if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDA22222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { + if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDAx2222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err) } @@ -265,7 +299,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/engineering/", RoleName: "alice-role", - RoleID: "AROA22222222222222222", + RoleID: "AROAx2222222222222222", Arn: "arn:aws:iam::000000000000:role/engineering/alice-role", CreateDate: created, AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -277,7 +311,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/engineering/platform/", RoleName: "bob-role", - RoleID: "AROA33333333333333333", + RoleID: "AROAx3333333333333333", Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role", CreateDate: created.Add(time.Second), AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -286,7 +320,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { { Path: "/ops/", RoleName: "carol-role", - RoleID: "AROA44444444444444444", + RoleID: "AROAx4444444444444444", Arn: "arn:aws:iam::000000000000:role/ops/carol-role", CreateDate: created.Add(2 * time.Second), AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, @@ -306,7 +340,7 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) { t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err) } - if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROA55555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { + if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROAx5555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err) } duplicateID := roles[2] @@ -395,7 +429,7 @@ func TestInternalStoreRolePolicyCRUD(t *testing.T) { if _, err := store.CreateRole(ctx, types.Role{ RoleName: "alice-role", - RoleID: "AROA22222222222222222", + RoleID: "AROAx2222222222222222", AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, }); err != nil { t.Fatalf("CreateRole: %v", err) @@ -511,3 +545,136 @@ func TestInternalStoreRolePolicyCRUD(t *testing.T) { t.Fatalf("DeleteRole after removing all policies: %v", err) } } + +func TestInternalStoreSessionCRUDAndExpiry(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + // GetSession compares Expiration against the real wall clock, so (unlike + // most other timestamps in this package's tests) now must track it. + now := time.Now().UTC() + session := types.Session{ + AccessKeyId: "ASIAeXAMPLE1234567890", + SecretAccessKey: "secret", + SessionToken: "token", + RoleArn: "arn:aws:iam::000000000000:role/my-role", + RoleName: "my-role", + RoleID: "AROAeXAMPLE1234567890", + RoleSessionName: "my-session", + Provider: "arn:aws:iam::000000000000:oidc-provider/example.com", + Audience: "client1", + Subject: "user1", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + + if _, err := store.CreateSession(ctx, session); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + got, err := store.GetSession(ctx, session.AccessKeyId) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if !reflect.DeepEqual(*got, session) { + t.Fatalf("GetSession = %#v, want %#v", *got, session) + } + + if _, err := store.GetSession(ctx, "ASIAUNKNOWN"); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("GetSession unknown access key err = %v, want ErrSessionNotFound", err) + } + + // A session persists across process restarts (round-trips through the + // same on-disk file the rest of the IAM store uses). + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + if _, err := reopened.GetSession(ctx, session.AccessKeyId); err != nil { + t.Fatalf("GetSession after reopen: %v", err) + } + + expired := types.Session{ + AccessKeyId: "ASIAeXPIRED1234567890", + CreateDate: now, + Expiration: now.Add(-time.Minute), + } + if _, err := reopened.CreateSession(ctx, expired); err != nil { + t.Fatalf("CreateSession expired: %v", err) + } + if _, err := reopened.GetSession(ctx, expired.AccessKeyId); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("GetSession expired err = %v, want ErrSessionNotFound", err) + } + + // Creating a new session opportunistically prunes the already-expired + // one from storage rather than letting it accumulate forever. + another := types.Session{ + AccessKeyId: "ASIAaNOTHER1234567890", + CreateDate: now, + Expiration: now.Add(time.Hour), + } + if _, err := reopened.CreateSession(ctx, another); err != nil { + t.Fatalf("CreateSession another: %v", err) + } + internal := reopened.(*InternalStore) + conf, err := internal.engine.GetIAM() + if err != nil { + t.Fatalf("GetIAM: %v", err) + } + if _, ok := conf.Sessions[expired.AccessKeyId]; ok { + t.Fatalf("expired session %q was not pruned: %#v", expired.AccessKeyId, conf.Sessions) + } + if _, ok := conf.Sessions[another.AccessKeyId]; !ok { + t.Fatalf("unexpired session %q missing after prune: %#v", another.AccessKeyId, conf.Sessions) + } +} + +func TestInternalStoreSessionCapPerRole(t *testing.T) { + // Each CreateSession call rewrites the whole IAM file, so hitting the + // real 1000 cap here would mean O(n^2) JSON work just to prove the cap + // is enforced. Lower it for the duration of the test instead. + orig := MaxActiveSessionsPerRole + MaxActiveSessionsPerRole = 5 + t.Cleanup(func() { MaxActiveSessionsPerRole = orig }) + + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + now := time.Now().UTC() + newSession := func(i int, roleArn string) types.Session { + return types.Session{ + AccessKeyId: fmt.Sprintf("ASIACAPPEDROLE%06d", i), + RoleArn: roleArn, + CreateDate: now, + Expiration: now.Add(time.Hour), + } + } + + const roleArn = "arn:aws:iam::000000000000:role/capped-role" + for i := range MaxActiveSessionsPerRole { + if _, err := store.CreateSession(ctx, newSession(i, roleArn)); err != nil { + t.Fatalf("CreateSession %d: %v", i, err) + } + } + + // The role is now at its cap - one more session for the same role must + // be rejected rather than accepted unboundedly. + _, err = store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole, roleArn)) + var apiErr iamerr.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode() != 400 { + t.Fatalf("CreateSession at cap err = %v, want a Throttling APIError", err) + } + + // A different role is entirely unaffected by the first role's cap. + const otherRoleArn = "arn:aws:iam::000000000000:role/other-role" + if _, err := store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole+1, otherRoleArn)); err != nil { + t.Fatalf("CreateSession for a different role: %v", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 5b9d9694..db356bbf 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -28,6 +28,7 @@ import ( vault "github.com/hashicorp/vault-client-go" "github.com/hashicorp/vault-client-go/schema" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" @@ -35,6 +36,60 @@ import ( const vaultRequestTimeout = 10 * time.Second +// maxCASRetries bounds the read-modify-write retry loop withUserCAS/ +// withRoleCAS/withOIDCProviderCAS run when a version-checked (CAS) write +// loses a race against a concurrent writer updating the same entity — +// mirroring the 3-attempt collision-retry loops already used elsewhere in +// this package for ID generation (see controller.go's CreateUser/CreateRole/ +// CreateAccessKey). +const maxCASRetries = 3 + +// errConcurrentModification is withUserCAS/withRoleCAS/withOIDCProviderCAS's +// internal signal that a replace* call's CAS write lost a race against +// another writer and should be retried; it never escapes to a caller +// directly — once retries are exhausted it's surfaced as +// iamerr.ConcurrentModification(), matching real IAM's documented +// ConcurrentModificationException. +var errConcurrentModification = errors.New("iamapi: concurrent modification") + +// errRenameCleanupFailed marks an error from deleteOldUserAfterRename: the +// rename's new record was created successfully, but deleting the stale +// record at the old name failed even after retrying (see +// renameDeleteRetries). It is surfaced only via errors.Is/wrapping — +// Vault's KV store has no multi-key transaction to make the two writes +// atomic, so this signals a state that needs operator attention rather than +// one an automatic retry of the whole operation can resolve (a caller +// retrying UpdateUser from scratch would now fail with EntityAlreadyExists +// against the very record it just created). +var errRenameCleanupFailed = errors.New("iamapi: rename cleanup failed") + +// kvVersion extracts a KV v2 secret version from a read response's metadata +// map. The generated schema client types Metadata as map[string]interface{}, +// but vault-client-go decodes its JSON body with a decoder configured to +// produce json.Number for numeric fields, not float64 — a plain +// metadata["version"].(float64) assertion never matches, so it silently fell +// through to the zero value on every call. Every version-checked (CAS) +// write's readVersion was therefore always 0 — the "create if it doesn't +// exist yet" sentinel — so any write to an already-existing document (i.e. +// every one of them past its first) sent cas:0 and was unconditionally +// rejected by Vault as a check-and-set mismatch. That surfaced as +// ConcurrentModificationException on withUserCAS/withRoleCAS/ +// withOIDCProviderCAS's every retry, deterministically, with no concurrent +// writer involved at all — confirmed by reproducing it single-threaded +// against a live Vault (CreateRole then PutRolePolicy, nothing else +// touching the record, still failed every time before this fix). +func kvVersion(metadata map[string]any) int32 { + switch v := metadata["version"].(type) { + case json.Number: + if n, err := v.Int64(); err == nil { + return int32(n) + } + case float64: + return int32(v) + } + return 0 +} + // VaultConfig holds all configuration options for the Vault-backed IAM storer. type VaultConfig struct { EndpointURL string @@ -193,53 +248,41 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return nil } -// findUserKey resolves name to the exact stored KV path segment (the -// original UserName casing used at creation), case-insensitively, by -// listing the users under secretStoragePath and comparing with EqualFold. -// AWS enforces case-insensitive UserName uniqueness but Vault's KV paths -// are plain case-sensitive strings, so a list+compare fallback is needed — -// KV has no native case-insensitive lookup. ok is false both when nothing -// matches and (harmlessly) when the prefix has no children at all. -func (s *VaultStore) findUserKey(name string) (string, bool, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return "", false, reauthErr - } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - return "", false, err - } - } - for _, key := range resp.Data.Keys { - if strings.EqualFold(key, name) { - return key, true, nil - } - } - return "", false, nil +// usersPath is the KV prefix under which users are stored, kept distinct +// from rolesPath/oidcProvidersPath/sessionsPath — mirroring their own +// isolation rationale — so listing users never picks up a sibling entity +// type's directory marker (e.g. "roles/") as if it were a username. +func (s *VaultStore) usersPath() string { + return s.secretStoragePath + "/users" +} + +// caseFoldKey case-folds name to the KV path segment (and inner data map +// key) an identity of that name is stored under. AWS enforces +// case-insensitive uniqueness for IAM names (UserName, RoleName) but +// Vault's KV paths are plain case-sensitive strings; storing every identity +// under its case-folded name — rather than the as-given casing, resolved by +// a separate list-and-compare lookup — makes uniqueness a property Vault's +// own CAS write enforces atomically, instead of a check-then-write race +// between two callers using different casings of the same name (e.g. +// "Alice" and "alice" both passing a list-based existence check and then +// both succeeding at CAS 0 on two different paths). The original, +// as-given casing is preserved in the identity's own UserName/RoleName +// field within the stored document. +func caseFoldKey(name string) string { + return strings.ToLower(name) } func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { - if _, ok, err := s.findUserKey(user.UserName); err != nil { - return nil, err - } else if ok { - return nil, iamerr.EntityAlreadyExistsUser(user.UserName) - } + key := caseFoldKey(user.UserName) userMap, err := userToVaultMap(user) if err != nil { return nil, fmt.Errorf("serialize user: %w", err) } - path := s.secretStoragePath + "/" + user.UserName + path := s.usersPath() + "/" + key req := schema.KvV2WriteRequest{ - Data: map[string]any{user.UserName: userMap}, + Data: map[string]any{key: userMap}, Options: map[string]any{ "cas": 0, }, @@ -268,56 +311,120 @@ func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User return cloneUser(user), nil } +// DeleteUser checks user against its dependency preconditions (no inline +// policies, no access keys) and then deletes it. The metadata-delete call +// Vault exposes has no CAS parameter of its own (unlike a KV write), so a +// plain read-check-then-delete would leave a window where a concurrent +// CreateAccessKey or PutUserPolicy lands between the check and the delete, +// and the delete proceeds anyway, orphaning the new key/policy against a +// user that no longer exists. Closing that window: after the +// dependency check, replaceUser writes the same (unchanged) record back +// with a CAS matching the version just read — succeeding only if nothing +// else has modified the record since — immediately before the actual +// delete, shrinking the race to the gap between two back-to-back Vault +// calls instead of the whole request lifecycle. A CAS conflict there means +// something changed after the check, so the whole check is retried +// (bounded by maxCASRetries) rather than deleting against stale +// information. func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { - user, err := s.GetUser(ctx, username) - if err != nil { - return err + for range maxCASRetries { + user, version, err := s.readUserVersion(username) + if err != nil { + return err + } + if len(user.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + if len(user.AccessKeys) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflict) + } + + if _, err := s.replaceUser(ctx, *user, version); err != nil { + if errors.Is(err, errConcurrentModification) { + continue + } + return err + } + + return s.deleteByPath("users/" + caseFoldKey(user.UserName)) } - if len(user.Policies.Inline) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) - } - if len(user.AccessKeys) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflict) - } - return s.deleteByPath(user.UserName) + return iamerr.ConcurrentModification() } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - canonical, ok, err := s.findUserKey(username) - if err != nil { - return nil, err - } - if !ok { - return nil, iamerr.NoSuchEntityUser(username) - } + user, _, err := s.readUserVersion(username) + return user, err +} - path := s.secretStoragePath + "/" + canonical +// readUserVersion resolves username the same way GetUser does, additionally +// returning the KV version the record was read at, so a mutation can write +// back with a matching CAS value instead of racing on a blind +// delete-then-recreate (see replaceUser). +func (s *VaultStore) readUserVersion(username string) (*types.User, int32, error) { + key := caseFoldKey(username) + path := s.usersPath() + "/" + key resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityUser(username) + return nil, 0, iamerr.NoSuchEntityUser(username) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, reauthErr } resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityUser(username) + return nil, 0, iamerr.NoSuchEntityUser(username) + } + return nil, 0, err + } + } + + user, err := parseVaultUser(resp.Data.Data, key) + if err != nil { + return nil, 0, err + } + return cloneUser(user), kvVersion(resp.Data.Metadata), nil +} + +// GetUserByAccessKeyID has no index to consult (unlike InternalStore's +// AccessKeyIndex) so it scans every user's access keys, mirroring +// GetAccessKeyLastUsed's existing linear scan. +func (s *VaultStore) GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } return nil, err } } - user, err := parseVaultUser(resp.Data.Data, canonical) - if err != nil { - return nil, err + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + return nil, err + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + return user, nil + } + } } - return cloneUser(user), nil + + return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return &ListUsersOutput{Users: []types.User{}}, nil @@ -329,7 +436,7 @@ func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*List } return nil, reauthErr } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return &ListUsersOutput{Users: []types.User{}}, nil @@ -384,7 +491,7 @@ func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*List } func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) { - user, err := s.GetUser(ctx, input.UserName) + user, version, err := s.readUserVersion(input.UserName) if err != nil { return nil, err } @@ -415,112 +522,194 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != originalName { - // Create at new path first to detect conflicts before deleting the old entry. + if caseFoldKey(user.UserName) != caseFoldKey(originalName) { + // A genuine rename to a different case-folded key (and therefore a + // different KV path): create at the new path first — its cas:0 + // write atomically detects a conflict, including one from a + // concurrent create/rename racing for the same new name — before + // deleting the old entry. A UserName change that's case-only (e.g. + // "Alice" -> "alice") case-folds to the *same* path, so it's handled + // below as an in-place update instead: routing it through + // CreateUser here would spuriously fail with EntityAlreadyExists + // against the very record being renamed. if _, err := s.CreateUser(ctx, *user); err != nil { return nil, err } - if err := s.deleteByPath(originalName); err != nil { + if err := s.deleteOldUserAfterRename(originalName); err != nil { return nil, err } - } else if _, err := s.replaceUser(ctx, *user); err != nil { + } else if _, err := s.replaceUser(ctx, *user, version); err != nil { return nil, err } return cloneUser(*user), nil } -// replaceUser overwrites the stored document for user.UserName by deleting -// all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceUser(ctx context.Context, user types.User) (*types.User, error) { - if err := s.deleteByPath(user.UserName); err != nil { - return nil, err +// renameDeleteRetries bounds deleteOldUserAfterRename's retries of the +// old-path delete that follows a successful create-at-new-path during a +// rename (roles have no rename operation, so only users need this). Vault +// has no multi-key transaction to make "create new, delete old" atomic, so +// a delete failure here (after the new record already exists) is the one +// window where two live records for the same identity can coexist; +// retrying a bounded number of times, with a short backoff, absorbs a +// transient failure (network blip, momentary 403) rather than leaving that +// window open on the first error. +const ( + renameDeleteRetries = 3 + renameDeleteBackoff = 200 * time.Millisecond +) + +// deleteOldUserAfterRename deletes the pre-rename user record at +// originalName after UpdateUser has already created the record at its new +// name, retrying up to renameDeleteRetries times. If every attempt fails, +// the error returned wraps errRenameCleanupFailed so callers/operators can +// recognize that the new record was created and the stale record at +// originalName still exists and needs manual removal — better than +// masking that state as an ordinary write error. +func (s *VaultStore) deleteOldUserAfterRename(originalName string) error { + var err error + for attempt := range renameDeleteRetries { + if attempt > 0 { + time.Sleep(renameDeleteBackoff) + } + if err = s.deleteByPath("users/" + caseFoldKey(originalName)); err == nil { + return nil + } } - return s.CreateUser(ctx, user) + return fmt.Errorf("%w: stale user record %q must be removed manually: %v", errRenameCleanupFailed, originalName, err) +} + +// replaceUser overwrites the stored document for user.UserName using a +// version-checked (CAS) write tied to readVersion — the KV version the +// caller most recently read the record at — instead of an unconditional +// delete-then-recreate. This way, two concurrent updates to the same user +// (e.g. a DeleteAccessKey revocation racing a PutUserPolicy call) can't +// have the second writer silently discard the first writer's change: a CAS +// mismatch fails with errConcurrentModification, for withUserCAS to retry. +func (s *VaultStore) replaceUser(ctx context.Context, user types.User, readVersion int32) (*types.User, error) { + userMap, err := userToVaultMap(user) + if err != nil { + return nil, fmt.Errorf("serialize user: %w", err) + } + + key := caseFoldKey(user.UserName) + path := s.usersPath() + "/" + key + req := schema.KvV2WriteRequest{ + Data: map[string]any{key: userMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + return nil, err + } + } + return cloneUser(user), nil +} + +// withUserCAS resolves username, applies mutate to the fetched user, and +// writes it back with a CAS matching the version it was read at, retrying +// (bounded by maxCASRetries) if a concurrent writer's update lands first — +// closing the lost-update race described in replaceUser's doc comment. +// mutate's own error (e.g. a quota or not-found error) is returned +// immediately, never retried — only a genuine CAS conflict is. +func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate func(*types.User) error) (*types.User, error) { + for range maxCASRetries { + user, version, err := s.readUserVersion(username) + if err != nil { + return nil, err + } + if err := mutate(user); err != nil { + return nil, err + } + result, err := s.replaceUser(ctx, *user, version) + if err == nil { + return result, nil + } + if !errors.Is(err, errConcurrentModification) { + return nil, err + } + } + return nil, iamerr.ConcurrentModification() } func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return nil, err - } - - if len(user.AccessKeys) >= MaxAccessKeysPerUser { - return nil, iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) - } - for _, key := range user.AccessKeys { - if key.AccessKeyId == input.AccessKeyID { - return nil, ErrAccessKeyIDAlreadyExists + var created types.AccessKey + if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + if len(user.AccessKeys) >= MaxAccessKeysPerUser { + return iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser) + } + for _, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + return ErrAccessKeyIDAlreadyExists + } } - } - user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ - AccessKeyId: input.AccessKeyID, - SecretAccessKey: input.SecretAccessKey, - Status: input.Status, - CreateDate: input.CreateDate, - }) - - if _, err := s.replaceUser(ctx, *user); err != nil { + user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{ + AccessKeyId: input.AccessKeyID, + SecretAccessKey: input.SecretAccessKey, + Status: input.Status, + CreateDate: input.CreateDate, + }) + created = types.AccessKey{ + UserName: input.UserName, + AccessKeyId: input.AccessKeyID, + Status: input.Status, + SecretAccessKey: input.SecretAccessKey, + CreateDate: input.CreateDate, + } + return nil + }); err != nil { return nil, err } - return &types.AccessKey{ - UserName: input.UserName, - AccessKeyId: input.AccessKeyID, - Status: input.Status, - SecretAccessKey: input.SecretAccessKey, - CreateDate: input.CreateDate, - }, nil + return &created, nil } func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return err - } - - found := false - for i, key := range user.AccessKeys { - if key.AccessKeyId == input.AccessKeyID { - user.AccessKeys[i].Status = input.Status - found = true - break + _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + for i, key := range user.AccessKeys { + if key.AccessKeyId == input.AccessKeyID { + user.AccessKeys[i].Status = input.Status + return nil + } } - } - if !found { return iamerr.NoSuchEntityAccessKey(input.AccessKeyID) - } - - _, err = s.replaceUser(ctx, *user) + }) return err } func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error { - user, err := s.GetUser(ctx, username) - if err != nil { - return err - } - - idx := -1 - for i, key := range user.AccessKeys { - if key.AccessKeyId == accessKeyID { - idx = i - break + _, err := s.withUserCAS(ctx, username, func(user *types.User) error { + idx := -1 + for i, key := range user.AccessKeys { + if key.AccessKeyId == accessKeyID { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityAccessKey(accessKeyID) - } - - user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) - - _, err = s.replaceUser(ctx, *user) + if idx == -1 { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) + return nil + }) return err } func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) @@ -528,7 +717,7 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { return nil, reauthErr } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) @@ -545,7 +734,7 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin for _, key := range user.AccessKeys { if key.AccessKeyId == accessKeyID { return &GetAccessKeyLastUsedOutput{ - UserName: username, + UserName: user.UserName, LastUsedDate: key.LastUsedDate, ServiceName: key.LastUsedService, Region: key.LastUsedRegion, @@ -557,6 +746,74 @@ func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID strin return nil, iamerr.NoSuchEntityAccessKey(accessKeyID) } +// recordAccessKeyUsageTimeout bounds RecordAccessKeyUsage's detached +// background update. +const recordAccessKeyUsageTimeout = 5 * time.Second + +// RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed metadata +// in its own background goroutine, detached from ctx, and always returns +// nil immediately: this runs on the hot path of every authenticated request +// (see iammiddleware.recordAccessKeyUsage), and a Vault round trip — plus, +// on a CAS conflict, withUserCAS's retry loop — is too expensive to add +// synchronously to every one of them. A failure (including one that +// exhausts those retries) is only logged, never surfaced: this is purely +// informational metadata, and a lost update under concurrent use is +// immaterial. +func (s *VaultStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), recordAccessKeyUsageTimeout) + defer cancel() + if err := s.recordAccessKeyUsage(ctx, accessKeyID, service, region, when); err != nil { + debuglogger.Logf("failed to record Vault access key last-used metadata for %q: %v", accessKeyID, err) + } + }() + return nil +} + +func (s *VaultStore) recordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error { + resp, err := s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + resp, err = s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityAccessKey(accessKeyID) + } + return err + } + } + + for _, username := range resp.Data.Keys { + user, err := s.GetUser(ctx, username) + if err != nil { + continue + } + if !slices.ContainsFunc(user.AccessKeys, func(k types.AccessKeyEntry) bool { return k.AccessKeyId == accessKeyID }) { + continue + } + + _, err = s.withUserCAS(ctx, username, func(u *types.User) error { + for i, key := range u.AccessKeys { + if key.AccessKeyId == accessKeyID { + u.AccessKeys[i].LastUsedDate = when + u.AccessKeys[i].LastUsedService = service + u.AccessKeys[i].LastUsedRegion = region + return nil + } + } + return iamerr.NoSuchEntityAccessKey(accessKeyID) + }) + return err + } + + return iamerr.NoSuchEntityAccessKey(accessKeyID) +} + func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) { user, err := s.GetUser(ctx, input.UserName) if err != nil { @@ -606,38 +863,34 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp } func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { - user, err := s.GetUser(ctx, input.UserName) - if err != nil { - return err - } - - newTotal := len(input.PolicyDocument) - replaceAt := -1 - for i, p := range user.Policies.Inline { - if p.PolicyName == input.PolicyName { - replaceAt = i - continue + _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) } - newTotal += len(p.PolicyDocument) - } - if newTotal > MaxInlinePolicyBytesPerUser { - return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) - } - now := time.Now().UTC().Truncate(time.Second) - if replaceAt >= 0 { - user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument - user.Policies.Inline[replaceAt].UpdateDate = now - } else { - user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ - PolicyName: input.PolicyName, - PolicyDocument: input.PolicyDocument, - CreateDate: now, - UpdateDate: now, - }) - } - - _, err = s.replaceUser(ctx, *user) + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + return nil + }) return err } @@ -658,25 +911,20 @@ func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName str } func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error { - user, err := s.GetUser(ctx, userName) - if err != nil { - return err - } - - idx := -1 - for i, p := range user.Policies.Inline { - if p.PolicyName == policyName { - idx = i - break + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityUserPolicy(userName, policyName) - } - - user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) - - _, err = s.replaceUser(ctx, *user) + if idx == -1 { + return iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + return nil + }) return err } @@ -722,9 +970,10 @@ func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPolicie } // deleteByPath permanently removes a secret and all its versions without -// checking for existence first. -func (s *VaultStore) deleteByPath(username string) error { - path := s.secretStoragePath + "/" + username +// checking for existence first. relPath is relative to secretStoragePath +// (e.g. "users/alice" or "sessions/AKIA..."). +func (s *VaultStore) deleteByPath(relPath string) error { + path := s.secretStoragePath + "/" + relPath _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) if err != nil { if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { @@ -739,44 +988,14 @@ func (s *VaultStore) deleteByPath(username string) error { } // rolesPath is the KV prefix under which roles are stored, kept distinct -// from secretStoragePath (which holds users) so listing one entity kind -// never has to filter out the other's keys. +// from usersPath so listing one entity kind never has to filter out the +// other's keys. func (s *VaultStore) rolesPath() string { return s.secretStoragePath + "/roles" } -// findRoleKey is findUserKey's counterpart for roles. -func (s *VaultStore) findRoleKey(name string) (string, bool, error) { - resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return "", false, reauthErr - } - resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) - if err != nil { - if vault.IsErrorStatus(err, http.StatusNotFound) { - return "", false, nil - } - return "", false, err - } - } - for _, key := range resp.Data.Keys { - if strings.EqualFold(key, name) { - return key, true, nil - } - } - return "", false, nil -} - func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { - if _, ok, err := s.findRoleKey(role.RoleName); err != nil { - return nil, err - } else if ok { - return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) - } + key := caseFoldKey(role.RoleName) role.EnsureRoleLastUsed() @@ -785,9 +1004,9 @@ func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role return nil, fmt.Errorf("serialize role: %w", err) } - path := s.rolesPath() + "/" + role.RoleName + path := s.rolesPath() + "/" + key req := schema.KvV2WriteRequest{ - Data: map[string]any{role.RoleName: roleMap}, + Data: map[string]any{key: roleMap}, Options: map[string]any{ "cas": 0, }, @@ -817,37 +1036,39 @@ func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role } func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { - canonical, ok, err := s.findRoleKey(roleName) - if err != nil { - return nil, err - } - if !ok { - return nil, iamerr.NoSuchEntityRole(roleName) - } + role, _, err := s.readRoleVersion(roleName) + return role, err +} - path := s.rolesPath() + "/" + canonical +// readRoleVersion is GetRole's counterpart to readUserVersion: it +// additionally returns the KV version the record was read at, so a +// mutation can write back with a matching CAS value instead of racing on a +// blind delete-then-recreate (see replaceRole). +func (s *VaultStore) readRoleVersion(roleName string) (*types.Role, int32, error) { + key := caseFoldKey(roleName) + path := s.rolesPath() + "/" + key resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityRole(roleName) + return nil, 0, iamerr.NoSuchEntityRole(roleName) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, reauthErr } resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityRole(roleName) + return nil, 0, iamerr.NoSuchEntityRole(roleName) } - return nil, err + return nil, 0, err } } - role, err := parseVaultRole(resp.Data.Data, canonical) + role, err := parseVaultRole(resp.Data.Data, key) if err != nil { - return nil, err + return nil, 0, err } - return cloneRole(role), nil + return cloneRole(role), kvVersion(resp.Data.Metadata), nil } func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) { @@ -921,60 +1142,68 @@ func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*List return out, nil } +// DeleteRole is DeleteUser's counterpart for roles - see its doc comment for +// why the dependency check (no inline policies) is confirmed via a same-data +// CAS write (replaceRole) immediately before the actual delete, instead of +// an unconditional delete straight after the check. func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error { - role, err := s.GetRole(ctx, roleName) - if err != nil { - return err + for range maxCASRetries { + role, version, err := s.readRoleVersion(roleName) + if err != nil { + return err + } + if len(role.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + + if _, err := s.replaceRole(ctx, *role, version); err != nil { + if errors.Is(err, errConcurrentModification) { + continue + } + return err + } + + return s.deleteRoleByPath(role.RoleName) } - if len(role.Policies.Inline) > 0 { - return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) - } - return s.deleteRoleByPath(role.RoleName) + return iamerr.ConcurrentModification() } func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { - role, err := s.GetRole(ctx, input.RoleName) - if err != nil { - return nil, err - } - role.AssumeRolePolicyDocument = input.PolicyDocument - - return s.replaceRole(ctx, *role) + return s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error { + role.AssumeRolePolicyDocument = input.PolicyDocument + return nil + }) } func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { - role, err := s.GetRole(ctx, input.RoleName) - if err != nil { - return err - } - - newTotal := len(input.PolicyDocument) - replaceAt := -1 - for i, p := range role.Policies.Inline { - if p.PolicyName == input.PolicyName { - replaceAt = i - continue + _, err := s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error { + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) } - newTotal += len(p.PolicyDocument) - } - if newTotal > MaxInlinePolicyBytesPerRole { - return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) - } - now := time.Now().UTC().Truncate(time.Second) - if replaceAt >= 0 { - role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument - role.Policies.Inline[replaceAt].UpdateDate = now - } else { - role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ - PolicyName: input.PolicyName, - PolicyDocument: input.PolicyDocument, - CreateDate: now, - UpdateDate: now, - }) - } - - _, err = s.replaceRole(ctx, *role) + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + return nil + }) return err } @@ -995,25 +1224,20 @@ func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName str } func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error { - role, err := s.GetRole(ctx, roleName) - if err != nil { - return err - } - - idx := -1 - for i, p := range role.Policies.Inline { - if p.PolicyName == policyName { - idx = i - break + _, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error { + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } } - } - if idx == -1 { - return iamerr.NoSuchEntityRolePolicy(roleName, policyName) - } - - role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) - - _, err = s.replaceRole(ctx, *role) + if idx == -1 { + return iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + return nil + }) return err } @@ -1058,19 +1282,66 @@ func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePolicie return out, nil } -// replaceRole overwrites the stored document for role.RoleName by deleting -// all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { - if err := s.deleteRoleByPath(role.RoleName); err != nil { - return nil, err +// replaceRole overwrites the stored document for role.RoleName using a +// version-checked (CAS) write tied to readVersion, instead of an +// unconditional delete-then-recreate — see replaceUser for the rationale. +func (s *VaultStore) replaceRole(ctx context.Context, role types.Role, readVersion int32) (*types.Role, error) { + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) } - return s.CreateRole(ctx, role) + + key := caseFoldKey(role.RoleName) + path := s.rolesPath() + "/" + key + req := schema.KvV2WriteRequest{ + Data: map[string]any{key: roleMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, errConcurrentModification + } + return nil, err + } + } + return cloneRole(role), nil +} + +// withRoleCAS is withUserCAS's counterpart for roles. +func (s *VaultStore) withRoleCAS(ctx context.Context, roleName string, mutate func(*types.Role) error) (*types.Role, error) { + for range maxCASRetries { + role, version, err := s.readRoleVersion(roleName) + if err != nil { + return nil, err + } + if err := mutate(role); err != nil { + return nil, err + } + result, err := s.replaceRole(ctx, *role, version) + if err == nil { + return result, nil + } + if !errors.Is(err, errConcurrentModification) { + return nil, err + } + } + return nil, iamerr.ConcurrentModification() } // deleteRoleByPath permanently removes a role secret and all its versions // without checking for existence first. func (s *VaultStore) deleteRoleByPath(roleName string) error { - path := s.rolesPath() + "/" + roleName + path := s.rolesPath() + "/" + caseFoldKey(roleName) _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) if err != nil { if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { @@ -1200,9 +1471,19 @@ func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCPr } func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + provider, _, err := s.readOIDCProviderVersion(arn) + return provider, err +} + +// readOIDCProviderVersion is GetOIDCProvider's counterpart to +// readUserVersion/readRoleVersion: it additionally returns the KV version +// the record was read at, so a mutation can write back with a matching CAS +// value instead of racing on a blind delete-then-recreate (see +// replaceOIDCProvider). +func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, int32, error) { url, err := iamutil.ParseOIDCProviderArn(arn) if err != nil { - return nil, err + return nil, 0, err } segment := oidcProviderPathSegment(url) path := s.oidcProvidersPath() + "/" + segment @@ -1210,25 +1491,25 @@ func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDC resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { - return nil, reauthErr + return nil, 0, reauthErr } resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) } - return nil, err + return nil, 0, err } } provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) if err != nil { - return nil, err + return nil, 0, err } - return cloneOIDCProvider(provider), nil + return cloneOIDCProvider(provider), kvVersion(resp.Data.Metadata), nil } func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { @@ -1319,58 +1600,91 @@ func (s *VaultStore) deleteOIDCProviderByURL(url string) error { return nil } -// AddClientIDToOIDCProvider / RemoveClientIDFromOIDCProvider / -// UpdateOIDCProviderThumbprint use non-atomic get-then-replace, mirroring -// the existing consistency model of UpdateAssumeRolePolicy/PutRolePolicy's -// Vault implementations — this codebase has no CAS-protected -// read-modify-write for Vault mutations today, and this does not introduce -// one. - func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - if slices.Contains(provider.ClientIDList, clientID) { + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + if slices.Contains(provider.ClientIDList, clientID) { + return nil + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) return nil - } - if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { - return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) - } - provider.ClientIDList = append(provider.ClientIDList, clientID) - return s.replaceOIDCProvider(ctx, *provider) + }) } func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - idx := slices.Index(provider.ClientIDList, clientID) - if idx == -1 { + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return nil + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) return nil - } - provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) - return s.replaceOIDCProvider(ctx, *provider) + }) } func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { - provider, err := s.GetOIDCProvider(ctx, arn) - if err != nil { - return err - } - provider.ThumbprintList = thumbprints - return s.replaceOIDCProvider(ctx, *provider) + return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + provider.ThumbprintList = thumbprints + return nil + }) } -// replaceOIDCProvider overwrites the stored document for provider.Url by -// deleting all existing versions and recreating with CAS=0. -func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider) error { - if err := s.deleteOIDCProviderByURL(provider.Url); err != nil { - return err +// replaceOIDCProvider overwrites the stored document for provider.Url using +// a version-checked (CAS) write tied to readVersion, instead of an +// unconditional delete-then-recreate — see replaceUser for the rationale. +func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider, readVersion int32) error { + segment := oidcProviderPathSegment(provider.Url) + path := s.oidcProvidersPath() + "/" + segment + + providerMap, err := oidcProviderToVaultMap(provider) + if err != nil { + return fmt.Errorf("serialize oidc provider: %w", err) } - _, err := s.CreateOIDCProvider(ctx, provider) - return err + req := schema.KvV2WriteRequest{ + Data: map[string]any{segment: providerMap}, + Options: map[string]any{"cas": readVersion}, + } + + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return errConcurrentModification + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return errConcurrentModification + } + return err + } + } + return nil +} + +// withOIDCProviderCAS is withUserCAS's counterpart for OIDC providers. +func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, mutate func(*types.OIDCProvider) error) error { + for range maxCASRetries { + provider, version, err := s.readOIDCProviderVersion(arn) + if err != nil { + return err + } + if err := mutate(provider); err != nil { + return err + } + err = s.replaceOIDCProvider(ctx, *provider, version) + if err == nil { + return nil + } + if !errors.Is(err, errConcurrentModification) { + return err + } + } + return iamerr.ConcurrentModification() } var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine") @@ -1411,6 +1725,220 @@ func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProv return provider, nil } +// sessionsPath is the KV prefix under which AssumeRoleWithWebIdentity +// sessions are stored, kept distinct from secretStoragePath/rolesPath/ +// oidcProvidersPath. +func (s *VaultStore) sessionsPath() string { + return s.secretStoragePath + "/sessions" +} + +func (s *VaultStore) CreateSession(ctx context.Context, session types.Session) (*types.Session, error) { + // Bound how many concurrently-active sessions a single role can + // accumulate — without this, one valid federated token replayed against + // AssumeRoleWithWebIdentity indefinitely grows the number of KV paths + // and metadata records this backend has to carry for that role. + count, err := s.activeSessionCountForRole(ctx, session.RoleArn) + if err != nil { + return nil, err + } + if count >= MaxActiveSessionsPerRole { + return nil, iamerr.GetAPIError(iamerr.ErrThrottling) + } + + path := s.sessionsPath() + "/" + session.AccessKeyId + + // Pin the secret's own TTL to the session's expiration via Vault's + // native KV v2 delete_version_after metadata, so an expired session is + // eventually purged from storage by Vault itself even if GetSession is + // never called again for it (e.g. a session minted once and never + // reused) — GetSession's own expired-session delete only reclaims + // storage for sessions someone actually looks up again. + // + // This must happen *before* the version below is written: Vault + // computes a version's deletion_time from whatever delete_version_after + // is in effect at the moment that version is written, not retroactively + // — setting it afterward leaves an already-written version with no + // deletion_time at all (confirmed against a live Vault server: a + // version written before delete_version_after was set was never + // scheduled for deletion, while one written after was). Best-effort: a + // failure here still leaves a fully functional (if not self-cleaning) + // session, so it's logged rather than failing the create. + if err := s.setSessionTTL(path, session.Expiration); err != nil { + debuglogger.Logf("failed to set Vault session TTL metadata for access key %q: %v", session.AccessKeyId, err) + } + + sessionMap, err := sessionToVaultMap(session) + if err != nil { + return nil, fmt.Errorf("serialize session: %w", err) + } + req := schema.KvV2WriteRequest{ + Data: map[string]any{session.AccessKeyId: sessionMap}, + Options: map[string]any{"cas": 0}, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + return nil, err + } + } + + cloned := session + return &cloned, nil +} + +// activeSessionCountForRole counts this backend's currently-active sessions +// belonging to roleArn, so CreateSession can enforce +// MaxActiveSessionsPerRole. GetSession is reused to read each candidate +// entry: it already purges an expired-but-not-yet-Vault-reaped session on +// read, so an expired session is neither counted nor left to inflate a +// future count. +func (s *VaultStore) activeSessionCountForRole(ctx context.Context, roleArn string) (int, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return 0, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return 0, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return 0, nil + } + return 0, err + } + } + + count := 0 + for _, key := range resp.Data.Keys { + session, err := s.GetSession(ctx, key) + if err != nil { + if errors.Is(err, ErrSessionNotFound) { + continue + } + return 0, err + } + if session.RoleArn == roleArn { + count++ + } + } + return count, nil +} + +// setSessionTTL sets path's KV v2 delete_version_after metadata to the +// duration remaining until expiration, so Vault purges the version itself +// once it's expired. +func (s *VaultStore) setSessionTTL(path string, expiration time.Time) error { + ttl := time.Until(expiration) + if ttl <= 0 { + ttl = time.Second + } + + req := schema.KvV2WriteMetadataRequest{DeleteVersionAfter: fmt.Sprintf("%.0fs", ttl.Seconds())} + _, err := s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...) + } + return err +} + +func (s *VaultStore) GetSession(_ context.Context, accessKeyID string) (*types.Session, error) { + path := s.sessionsPath() + "/" + accessKeyID + + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + // Either this access key never existed, or Vault's own + // delete_version_after TTL (see setSessionTTL) already + // soft-deleted the version — confirmed live: Vault answers a + // read for a soft-deleted-but-not-yet-destroyed version with + // 404, not 200-with-null-data. Either way, best-effort purge + // the lingering metadata record now, since Vault doesn't + // appear to reclaim it on its own once merely soft-deleted. + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound + } + return nil, err + } + } + + session, err := parseVaultSession(resp.Data.Data, accessKeyID) + if err == nil && session.Expiration.After(time.Now().UTC()) { + cloned := session + return &cloned, nil + } + + // Readable but our own Expiration field says it's past due anyway + // (should be rare/racy, since setSessionTTL pins Vault's own TTL to + // this same value) — purge now rather than waiting on Vault. + s.purgeSession(accessKeyID) + return nil, ErrSessionNotFound +} + +// purgeSession permanently deletes accessKeyID's session metadata and +// version record. Best-effort: a failure just leaves the (already +// not-found-to-the-caller) entry lingering until some later call retries +// the purge or Vault's own cleanup eventually catches it. +func (s *VaultStore) purgeSession(accessKeyID string) { + if err := s.deleteByPath("sessions/" + accessKeyID); err != nil { + debuglogger.Logf("failed to delete expired Vault session for access key %q: %v", accessKeyID, err) + } +} + +var errInvalidVaultSession = errors.New("invalid session entry in vault secrets engine") + +func sessionToVaultMap(session types.Session) (map[string]any, error) { + b, err := json.Marshal(session) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// parseVaultSession reconstructs a Session from the raw map[string]any +// vault returns. The outer key is the AccessKeyId. +func parseVaultSession(data map[string]any, accessKeyID string) (types.Session, error) { + raw, ok := data[accessKeyID] + if !ok { + return types.Session{}, errInvalidVaultSession + } + sessionMap, ok := raw.(map[string]any) + if !ok { + return types.Session{}, errInvalidVaultSession + } + b, err := json.Marshal(sessionMap) + if err != nil { + return types.Session{}, fmt.Errorf("re-marshal vault session: %w", err) + } + var session types.Session + if err := json.Unmarshal(b, &session); err != nil { + return types.Session{}, fmt.Errorf("unmarshal vault session: %w", err) + } + return session, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/identity.go b/iamapi/types/identity.go new file mode 100644 index 00000000..f28314ec --- /dev/null +++ b/iamapi/types/identity.go @@ -0,0 +1,48 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +// Identity is the caller identity the auth middleware resolves for a +// request, shared across the auth middleware, the policy middleware, and +// controllers (GetCallerIdentity) so the access key only ever needs to be +// resolved once per request. +// +// Exactly one of IsRoot, User, or Session is set: +// - IsRoot: the configured root credential. Bypasses policy evaluation +// entirely, matching real AWS's root user. +// - User: a long-term (AKIA…) IAM user credential. IdentityPolicies holds +// that user's own inline policy documents. +// - Session: a temporary (ASIA…) credential minted by +// AssumeRoleWithWebIdentity. Role is the assumed role; IdentityPolicies +// holds the role's inline policy documents, and SessionPolicy — if +// non-empty — is an additional filter that can only narrow, never +// widen, what the role otherwise allows (Effective permissions = Role +// identity-based permissions ∩ Session policy permissions). +type Identity struct { + IsRoot bool + User *User + Role *Role + Session *Session + + // IdentityPolicies are the inline policies to evaluate for + // authorization: the User's own policies, or the assumed Role's + // policies for a Session. Unset (nil) when IsRoot. + IdentityPolicies []PolicyEntry + + // SessionPolicy is the session's own inline policy document (the + // AssumeRoleWithWebIdentity Policy parameter), or "" if none was + // supplied. Only ever set alongside Session. + SessionPolicy string +} diff --git a/iamapi/types/sts.go b/iamapi/types/sts.go new file mode 100644 index 00000000..2ee4f020 --- /dev/null +++ b/iamapi/types/sts.go @@ -0,0 +1,98 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +// Session is the storage-layer representation of a temporary credential set +// minted by AssumeRoleWithWebIdentity. It is never marshaled to XML +// directly — GetCallerIdentity and (in a later change) S3 request +// authentication read it back by AccessKeyId to resolve the calling +// identity. +type Session struct { + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + RoleArn string `json:"roleArn"` + RoleName string `json:"roleName"` + RoleID string `json:"roleId"` + RoleSessionName string `json:"roleSessionName"` + Provider string `json:"provider"` + Audience string `json:"audience"` + Subject string `json:"subject"` + CreateDate time.Time `json:"createDate"` + Expiration time.Time `json:"expiration"` + // Policy is the optional inline session policy document supplied via + // AssumeRoleWithWebIdentity's Policy parameter, or "" if none was + // supplied. It can only narrow, never widen, the assumed role's own + // permissions. + Policy string `json:"policy,omitempty"` +} + +// Credentials is the temporary security credential set returned by +// AssumeRoleWithWebIdentity. +type Credentials struct { + AccessKeyId string + SecretAccessKey string + SessionToken string + Expiration time.Time +} + +// AssumedRoleUser identifies the principal produced by assuming a role. +type AssumedRoleUser struct { + AssumedRoleId string + Arn string +} + +type AssumeRoleWithWebIdentityResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithWebIdentityResponse"` + Result AssumeRoleWithWebIdentityResult `xml:"AssumeRoleWithWebIdentityResult"` + ResponseMetadata ResponseMetadata +} + +func (r *AssumeRoleWithWebIdentityResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type AssumeRoleWithWebIdentityResult struct { + Audience string `xml:",omitempty"` + AssumedRoleUser AssumedRoleUser + Provider string + Credentials Credentials + SubjectFromWebIdentityToken string + // PackedPolicySize is a percentage indicating how close the request's + // session policy came to its size quota; nil (and therefore omitted, + // matching AWS) when no session Policy parameter was supplied. + PackedPolicySize *int64 `xml:",omitempty"` +} + +type GetCallerIdentityResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetCallerIdentityResponse"` + Result GetCallerIdentityResult `xml:"GetCallerIdentityResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetCallerIdentityResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetCallerIdentityResult struct { + Arn string + UserId string + Account string +} diff --git a/internal/httpctx/context_keys.go b/internal/httpctx/context_keys.go index 4c7fba7f..5d9fa59e 100644 --- a/internal/httpctx/context_keys.go +++ b/internal/httpctx/context_keys.go @@ -37,6 +37,7 @@ const ( ContextKeyRequestID ContextKey = "request-id" ContextKeyHostID ContextKey = "host-id" ContextKeyWebsiteConfig ContextKey = "website-config" + ContextKeyCallerIdentity ContextKey = "iam-caller-identity" ) func (ck ContextKey) Set(ctx fiber.Ctx, val any) { diff --git a/internal/sigv4auth/auth.go b/internal/sigv4auth/auth.go index 73c54790..92dd4120 100644 --- a/internal/sigv4auth/auth.go +++ b/internal/sigv4auth/auth.go @@ -27,9 +27,14 @@ const ( Terminal = "aws4_request" ServiceS3 = "s3" ServiceIAM = "iam" + ServiceSTS = "sts" ISO8601Format = "20060102T150405Z" YYYYMMDD = "20060102" + + // HeaderSecurityToken is the header a temporary credential's + // SessionToken is presented in, matching AWS's X-Amz-Security-Token. + HeaderSecurityToken = "X-Amz-Security-Token" ) type ParseErrorKind string diff --git a/internal/sigv4auth/compare.go b/internal/sigv4auth/compare.go new file mode 100644 index 00000000..3015621e --- /dev/null +++ b/internal/sigv4auth/compare.go @@ -0,0 +1,33 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sigv4auth + +import "crypto/subtle" + +// SecureCompare reports whether a and b are equal, comparing in time +// independent of their shared-prefix length. Used for authentication +// secrets — a computed SigV4 signature against the one the caller supplied, +// or a session token against its stored value — where an ordinary == +// comparison's early-exit on the first differing byte could, in principle, +// leak prefix-match information to a sufficiently patient and precise +// remote timing attacker. A length mismatch is reported as unequal without +// running the constant-time comparison at all: subtle.ConstantTimeCompare +// requires equal-length inputs, and the length of a fixed-format +// signature/token is not itself secret. +func SecureCompare(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/sigv4auth/compare_test.go b/internal/sigv4auth/compare_test.go new file mode 100644 index 00000000..8af89163 --- /dev/null +++ b/internal/sigv4auth/compare_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sigv4auth + +import "testing" + +func TestSecureCompare(t *testing.T) { + tests := []struct { + name string + a, b string + want bool + }{ + {"equal", "abc123", "abc123", true}, + {"different content, same length", "abc123", "abc124", false}, + {"different length", "abc123", "abc1234", false}, + {"empty vs empty", "", "", true}, + {"empty vs non-empty", "", "a", false}, + {"shares a long common prefix but differs at the end", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay", false}, + {"differs only in the first byte", "xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "yaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SecureCompare(tt.a, tt.b); got != tt.want { + t.Errorf("SecureCompare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/internal/sigv4auth/query.go b/internal/sigv4auth/query.go index 5fd2da5c..6ad04c6c 100644 --- a/internal/sigv4auth/query.go +++ b/internal/sigv4auth/query.go @@ -278,7 +278,11 @@ func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash strin req, payloadHash, service, auth.Region, tdate, signedHdrs, func(options *v4.SignerOptions) { options.DisableURIPathEscaping = opts.DisableURIPathEscaping - if debuglogger.IsDebugEnabled() { + // See the identical comment in verify.go's CheckSignature: this + // logger dumps a complete, replayable signed URL (including + // X-Amz-Signature and any session token) unredacted, so it may + // only run at LevelUnsafe. + if debuglogger.IsUnsafeEnabled() { options.LogSigning = true options.Logger = logging.NewStandardLogger(os.Stderr) } @@ -293,7 +297,7 @@ func CheckQuerySignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash strin } signature := urlParts.Query().Get(QuerySignature) - if signature != auth.Signature { + if !SecureCompare(signature, auth.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, StringToSign: signMeta.StringToSign, diff --git a/internal/sigv4auth/verify.go b/internal/sigv4auth/verify.go index 08f6c790..ca02567d 100644 --- a/internal/sigv4auth/verify.go +++ b/internal/sigv4auth/verify.go @@ -88,7 +88,12 @@ func CheckSignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, td req, payloadHash, service, auth.Region, tdate, signedHdrs, func(options *v4.SignerOptions) { options.DisableURIPathEscaping = opts.DisableURIPathEscaping - if debuglogger.IsDebugEnabled() { + // The signer's diagnostic logger prints the canonical request, + // string-to-sign, and (for presigned requests) the complete + // signed URL verbatim, bypassing the redaction layer entirely. + // That's replayable signature/session-token material, so only + // enable it at LevelUnsafe, never at plain debug. + if debuglogger.IsUnsafeEnabled() { options.LogSigning = true options.Logger = logging.NewStandardLogger(os.Stderr) } @@ -102,7 +107,7 @@ func CheckSignature(ctx fiber.Ctx, auth AuthData, secret, payloadHash string, td return nil, err } - if auth.Signature != genAuth.Signature { + if !SecureCompare(auth.Signature, genAuth.Signature) { return nil, &SignatureMismatchError{ AccessKeyID: auth.Access, StringToSign: signMeta.StringToSign, diff --git a/s3api/admin-server.go b/s3api/admin-server.go index 09460dfd..c9f40048 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -75,6 +75,9 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | adm | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } // initialize requestId middleware diff --git a/s3api/server.go b/s3api/server.go index efce34e5..fa798049 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -131,6 +131,9 @@ func New( if !server.quiet { app.Use("*", logger.New(logger.Config{ Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + }, })) } diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 6982aff6..23d8d0ff 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1443,6 +1443,109 @@ func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) } +func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { + ts.Run(IAMAssumeRoleWithWebIdentity_missing_role_arn) + ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_too_short) + ts.Run(IAMAssumeRoleWithWebIdentity_malformed_duration) + ts.Run(IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action) + ts.Run(IAMAssumeRoleWithWebIdentity_malformed_token) + ts.Run(IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max) + ts.Run(IAMAssumeRoleWithWebIdentity_nonexistent_role) + ts.Run(IAMAssumeRoleWithWebIdentity_no_matching_principal) + ts.Run(IAMAssumeRoleWithWebIdentity_no_issuer_match) + ts.Run(IAMAssumeRoleWithWebIdentity_condition_failed) + ts.Run(IAMAssumeRoleWithWebIdentity_explicit_deny) + ts.Run(IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list) + ts.Run(IAMAssumeRoleWithWebIdentity_empty_client_id_list) + ts.Run(IAMAssumeRoleWithWebIdentity_idp_communication_error) + ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_policy_arns_rejected) + ts.Run(IAMAssumeRoleWithWebIdentity_provider_id_rejected) + ts.Run(IAMAssumeRoleWithWebIdentity_session_policy_too_large) + ts.Run(IAMAssumeRoleWithWebIdentity_session_policy_invalid) + ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_matches) + ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch) +} + +func TestIAMGetCallerIdentity(ts *TestState) { + ts.Run(IAMGetCallerIdentity_root_success) + ts.Run(IAMGetCallerIdentity_user_success) + ts.Run(IAMGetCallerIdentity_unknown_access_key) + ts.Run(IAMGetCallerIdentity_no_auth) + ts.Run(IAMGetCallerIdentity_wrong_version_is_invalid_action) + ts.Run(IAMGetCallerIdentity_incorrect_service_scope) +} + +func TestIAMAccessControl(ts *TestState) { + ts.Run(IAMAccessControl_ImplicitDenyNoMatchingPolicy) + ts.Run(IAMAccessControl_AllowGrantsMatchingRequest) + ts.Run(IAMAccessControl_NonMatchingStatementDoesNotGrant) + ts.Run(IAMAccessControl_ExplicitDenyOverridesAllow) + ts.Run(IAMAccessControl_MultipleStatementsEvaluatedIndependently) + ts.Run(IAMAccessControl_MultipleInlinePoliciesCombinedAllow) + ts.Run(IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins) + ts.Run(IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies) + ts.Run(IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow) + ts.Run(IAMAccessControl_ActionMatchingVariants) + ts.Run(IAMAccessControl_ActionAllowOneDenyAnotherByOmission) + ts.Run(IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow) + ts.Run(IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded) + ts.Run(IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded) + ts.Run(IAMAccessControl_ResourceMatchingVariants) + ts.Run(IAMAccessControl_ResourceOneAllowedOneDeniedSameAction) + ts.Run(IAMAccessControl_ResourceWildcardRequiredForListAction) + ts.Run(IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow) + ts.Run(IAMAccessControl_NotResourceExcludesTarget) + ts.Run(IAMAccessControl_NotResourceMultipleExcludedResources) + ts.Run(IAMAccessControl_NotResourceWildcardExclusion) + ts.Run(IAMAccessControl_ConditionStringOperators) + ts.Run(IAMAccessControl_ConditionStringMultipleExpectedValuesOR) + ts.Run(IAMAccessControl_ConditionArnOperators) + ts.Run(IAMAccessControl_ConditionIpAddressRealSourceIp) + ts.Run(IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow) + ts.Run(IAMAccessControl_ConditionMultipleContextKeysANDed) + ts.Run(IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply) + ts.Run(IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins) + ts.Run(IAMAccessControl_ConditionOneFailedConditionVoidsStatement) + ts.Run(IAMAccessControl_ConditionNullPrincipalTag) + ts.Run(IAMAccessControl_ConditionIfExistsPrincipalTag) + ts.Run(IAMAccessControl_ConditionResourceTagOnTarget) + ts.Run(IAMAccessControl_ConditionRequestTagOnCreateUser) + ts.Run(IAMAccessControl_ConditionCurrentTimeBroadWindow) + ts.Run(IAMAccessControl_ConditionNumericOperators) + ts.Run(IAMAccessControl_ConditionDateOperators) + ts.Run(IAMAccessControl_ConditionBoolOperator) + ts.Run(IAMAccessControl_ConditionNullOperatorClaim) + ts.Run(IAMAccessControl_ConditionBinaryEqualsOperator) + ts.Run(IAMAccessControl_ConditionForAnyValueOperator) + ts.Run(IAMAccessControl_ConditionForAllValuesOperator) + ts.Run(IAMAccessControl_ConditionIfExistsTrustClaim) + ts.Run(IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust) + ts.Run(IAMAccessControl_TrustPolicyFederatedExactMatchAllowed) + ts.Run(IAMAccessControl_TrustPolicyFederatedWrongProviderDenied) + ts.Run(IAMAccessControl_TrustPolicyFederatedArrayMatchesAny) + ts.Run(IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored) + ts.Run(IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed) + ts.Run(IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied) + ts.Run(IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed) + ts.Run(IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied) + ts.Run(IAMAccessControl_TrustPolicyAudienceCorrectAllowed) + ts.Run(IAMAccessControl_TrustPolicyAudienceIncorrectDenied) + ts.Run(IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed) + ts.Run(IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch) + ts.Run(IAMAccessControl_TrustPolicyExplicitDenyStatement) + ts.Run(IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants) + ts.Run(IAMAccessControl_TrustPolicyMissingRequiredClaimDenied) + ts.Run(IAMAccessControl_UserInlinePolicyWorkflow) + ts.Run(IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath) + ts.Run(IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision) + ts.Run(IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy) + ts.Run(IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer) + ts.Run(IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1476,6 +1579,9 @@ func TestIAM(ts *TestState) { TestIAMAddClientIDToOpenIDConnectProvider(ts) TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) TestIAMUpdateOpenIDConnectProviderThumbprint(ts) + TestIAMAssumeRoleWithWebIdentity(ts) + TestIAMGetCallerIdentity(ts) + TestIAMAccessControl(ts) } func TestAccessControl(ts *TestState) { @@ -1757,1105 +1863,1199 @@ type IntTests map[string]IntTest func GetIntTests() IntTests { return IntTests{ - "Authentication_invalid_auth_header": Authentication_invalid_auth_header, - "Authentication_unsupported_signature_version": Authentication_unsupported_signature_version, - "Authentication_missing_components": Authentication_missing_components, - "Authentication_malformed_component": Authentication_malformed_component, - "Authentication_missing_credentials": Authentication_missing_credentials, - "Authentication_missing_signedheaders": Authentication_missing_signedheaders, - "Authentication_missing_signature": Authentication_missing_signature, - "Authentication_malformed_credential": Authentication_malformed_credential, - "Authentication_credentials_invalid_terminal": Authentication_credentials_invalid_terminal, - "Authentication_credentials_incorrect_service": Authentication_credentials_incorrect_service, - "Authentication_credentials_incorrect_region": Authentication_credentials_incorrect_region, - "Authentication_credentials_invalid_date": Authentication_credentials_invalid_date, - "Authentication_credentials_future_date": Authentication_credentials_future_date, - "Authentication_credentials_past_date": Authentication_credentials_past_date, - "Authentication_credentials_non_existing_access_key": Authentication_credentials_non_existing_access_key, - "Authentication_missing_date_header": Authentication_missing_date_header, - "Authentication_invalid_date_header": Authentication_invalid_date_header, - "Authentication_date_mismatch": Authentication_date_mismatch, - "Authentication_incorrect_payload_hash": Authentication_incorrect_payload_hash, - "Authentication_invalid_sha256_payload_hash": Authentication_invalid_sha256_payload_hash, - "Authentication_unsigned_required_header": Authentication_unsigned_required_header, - "Authentication_unsigned_non_required_header": Authentication_unsigned_non_required_header, - "Authentication_signature_error_incorrect_secret_key": Authentication_signature_error_incorrect_secret_key, - "Authentication_sigv2_not_supported": Authentication_sigv2_not_supported, - "Authentication_with_expect_header": Authentication_with_expect_header, - "IAMAuth_invalid_auth_header": IAMAuth_invalid_auth_header, - "IAMAuth_unsupported_signature_version": IAMAuth_unsupported_signature_version, - "IAMAuth_malformed_component": IAMAuth_malformed_component, - "IAMAuth_missing_authorization_component": IAMAuth_missing_authorization_component, - "IAMAuth_malformed_credential": IAMAuth_malformed_credential, - "IAMAuth_credentials_invalid_terminal": IAMAuth_credentials_invalid_terminal, - "IAMAuth_credentials_incorrect_service": IAMAuth_credentials_incorrect_service, - "IAMAuth_credentials_incorrect_region": IAMAuth_credentials_incorrect_region, - "IAMAuth_credentials_invalid_date": IAMAuth_credentials_invalid_date, - "IAMAuth_credentials_future_date": IAMAuth_credentials_future_date, - "IAMAuth_credentials_past_date": IAMAuth_credentials_past_date, - "IAMAuth_credentials_non_existing_access_key": IAMAuth_credentials_non_existing_access_key, - "IAMAuth_missing_date_header": IAMAuth_missing_date_header, - "IAMAuth_invalid_date_header": IAMAuth_invalid_date_header, - "IAMAuth_date_mismatch": IAMAuth_date_mismatch, - "IAMAuth_invalid_sha256_payload_hash_ignored": IAMAuth_invalid_sha256_payload_hash_ignored, - "IAMAuth_unsigned_required_header": IAMAuth_unsigned_required_header, - "IAMAuth_unsigned_non_required_header": IAMAuth_unsigned_non_required_header, - "IAMAuth_signature_error_incorrect_secret_key": IAMAuth_signature_error_incorrect_secret_key, - "IAMAuth_sigv2_not_supported": IAMAuth_sigv2_not_supported, - "IAMAuth_with_expect_header": IAMAuth_with_expect_header, - "IAMQueryAuth_success": IAMQueryAuth_success, - "IAMQueryAuth_security_token_not_supported": IAMQueryAuth_security_token_not_supported, - "IAMQueryAuth_unsupported_algorithm": IAMQueryAuth_unsupported_algorithm, - "IAMQueryAuth_ECDSA_not_supported": IAMQueryAuth_ECDSA_not_supported, - "IAMQueryAuth_missing_query_parameters": IAMQueryAuth_missing_query_parameters, - "IAMQueryAuth_malformed_credential": IAMQueryAuth_malformed_credential, - "IAMQueryAuth_credentials_invalid_terminal": IAMQueryAuth_credentials_invalid_terminal, - "IAMQueryAuth_credentials_incorrect_service": IAMQueryAuth_credentials_incorrect_service, - "IAMQueryAuth_credentials_incorrect_region": IAMQueryAuth_credentials_incorrect_region, - "IAMQueryAuth_credentials_invalid_date": IAMQueryAuth_credentials_invalid_date, - "IAMQueryAuth_non_existing_access_key": IAMQueryAuth_non_existing_access_key, - "IAMQueryAuth_invalid_date": IAMQueryAuth_invalid_date, - "IAMQueryAuth_date_mismatch": IAMQueryAuth_date_mismatch, - "IAMQueryAuth_unsigned_query_parameter": IAMQueryAuth_unsigned_query_parameter, - "IAMQueryAuth_incorrect_secret_key": IAMQueryAuth_incorrect_secret_key, - "IAMQueryAuth_invalid_sha256_payload_hash_ignored": IAMQueryAuth_invalid_sha256_payload_hash_ignored, - "IAMQueryAuth_with_expect_header": IAMQueryAuth_with_expect_header, - "IAMCreateUser_user_already_exists": IAMCreateUser_user_already_exists, - "IAMCreateUser_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, - "IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name, - "IAMCreateUser_long_user_name": IAMCreateUser_long_user_name, - "IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name, - "IAMCreateUser_invalid_tag_key": IAMCreateUser_invalid_tag_key, - "IAMCreateUser_invalid_tag_value": IAMCreateUser_invalid_tag_value, - "IAMCreateUser_long_tag_key": IAMCreateUser_long_tag_key, - "IAMCreateUser_long_tag_value": IAMCreateUser_long_tag_value, - "IAMCreateUser_duplicate_tag_keys": IAMCreateUser_duplicate_tag_keys, - "IAMCreateUser_success": IAMCreateUser_success, - "IAMCreateUser_default_path": IAMCreateUser_default_path, - "IAMCreateUser_invalid_path": IAMCreateUser_invalid_path, - "IAMCreateUser_long_path": IAMCreateUser_long_path, - "IAMGetUser_long_user_name": IAMGetUser_long_user_name, - "IAMGetUser_invalid_user_name": IAMGetUser_invalid_user_name, - "IAMGetUser_non_existing_user": IAMGetUser_non_existing_user, - "IAMGetUser_success": IAMGetUser_success, - "IAMGetUser_root_user": IAMGetUser_root_user, - "IAMListUsers_invalid_path_prefix": IAMListUsers_invalid_path_prefix, - "IAMListUsers_long_path_prefix": IAMListUsers_long_path_prefix, - "IAMListUsers_invalid_max_items": IAMListUsers_invalid_max_items, - "IAMListUsers_invalid_max_items_format": IAMListUsers_invalid_max_items_format, - "IAMListUsers_empty_result": IAMListUsers_empty_result, - "IAMListUsers_success": IAMListUsers_success, - "IAMListUsers_path_prefix": IAMListUsers_path_prefix, - "IAMListUsers_pagination": IAMListUsers_pagination, - "IAMListUsers_path_prefix_pagination": IAMListUsers_path_prefix_pagination, - "IAMDeleteUser_invalid_user_name": IAMDeleteUser_invalid_user_name, - "IAMDeleteUser_long_user_name": IAMDeleteUser_long_user_name, - "IAMDeleteUser_non_existing_user": IAMDeleteUser_non_existing_user, - "IAMDeleteUser_has_access_keys": IAMDeleteUser_has_access_keys, - "IAMDeleteUser_success": IAMDeleteUser_success, - "IAMUpdateUser_invalid_user_name": IAMUpdateUser_invalid_user_name, - "IAMUpdateUser_long_user_name": IAMUpdateUser_long_user_name, - "IAMUpdateUser_invalid_new_user_name": IAMUpdateUser_invalid_new_user_name, - "IAMUpdateUser_long_new_user_name": IAMUpdateUser_long_new_user_name, - "IAMUpdateUser_non_existing_user": IAMUpdateUser_non_existing_user, - "IAMUpdateUser_invalid_new_path": IAMUpdateUser_invalid_new_path, - "IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path, - "IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists, - "IAMUpdateUser_success": IAMUpdateUser_success, - "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, - "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, - "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, - "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, - "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, - "IAMCreateAccessKey_success": IAMCreateAccessKey_success, - "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, - "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, - "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, - "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, - "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, - "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, - "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, - "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, - "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, - "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, - "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, - "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, - "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, - "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, - "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, - "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, - "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, - "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, - "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, - "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, - "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, - "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, - "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, - "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, - "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, - "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, - "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, - "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, - "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, - "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, - "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, - "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, - "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, - "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, - "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, - "IAMListAccessKeys_success": IAMListAccessKeys_success, - "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, - "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, - "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, - "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, - "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, - "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, - "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, - "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, - "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, - "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, - "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, - "IAMPutUserPolicy_success": IAMPutUserPolicy_success, - "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, - "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, - "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, - "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, - "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, - "IAMGetUserPolicy_success": IAMGetUserPolicy_success, - "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, - "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, - "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, - "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, - "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, - "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, - "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, - "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, - "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, - "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, - "IAMListUserPolicies_success": IAMListUserPolicies_success, - "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, - "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, - "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, - "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, - "IAMCreateRole_already_exists": IAMCreateRole_already_exists, - "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, - "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, - "IAMCreateRole_long_path": IAMCreateRole_long_path, - "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, - "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, - "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, - "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, - "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, - "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, - "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, - "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, - "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, - "IAMCreateRole_success": IAMCreateRole_success, - "IAMCreateRole_defaults": IAMCreateRole_defaults, - "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, - "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, - "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, - "IAMGetRole_long_role_name": IAMGetRole_long_role_name, - "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, - "IAMGetRole_success": IAMGetRole_success, - "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, - "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, - "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, - "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, - "IAMListRoles_empty_result": IAMListRoles_empty_result, - "IAMListRoles_success": IAMListRoles_success, - "IAMListRoles_path_prefix": IAMListRoles_path_prefix, - "IAMListRoles_pagination": IAMListRoles_pagination, - "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, - "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, - "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, - "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, - "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, - "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, - "IAMDeleteRole_success": IAMDeleteRole_success, - "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, - "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, - "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, - "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, - "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, - "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, - "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, - "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, - "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, - "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, - "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, - "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, - "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, - "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, - "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, - "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, - "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, - "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, - "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, - "IAMPutRolePolicy_success": IAMPutRolePolicy_success, - "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, - "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, - "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, - "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, - "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, - "IAMGetRolePolicy_success": IAMGetRolePolicy_success, - "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, - "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, - "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, - "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, - "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, - "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, - "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, - "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, - "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, - "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, - "IAMListRolePolicies_success": IAMListRolePolicies_success, - "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, - "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, - "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, - "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, - "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, - "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, - "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, - "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, - "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, - "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, - "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, - "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, - "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, - "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, - "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, - "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, - "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, - "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, - "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, - "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, - "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, - "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, - "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, - "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, - "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, - "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, - "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, - "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, - "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, - "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, - "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, - "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, - "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, - "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, - "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, - "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, - "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, - "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, - "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, - "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, - "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, - "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, - "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, - "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, - "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, - "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, - "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, - "PresignedAuth_missing_signature_query_param": PresignedAuth_missing_signature_query_param, - "PresignedAuth_missing_credentials_query_param": PresignedAuth_missing_credentials_query_param, - "PresignedAuth_malformed_creds_invalid_parts": PresignedAuth_malformed_creds_invalid_parts, - "PresignedAuth_creds_invalid_terminal": PresignedAuth_creds_invalid_terminal, - "PresignedAuth_creds_incorrect_service": PresignedAuth_creds_incorrect_service, - "PresignedAuth_creds_incorrect_region": PresignedAuth_creds_incorrect_region, - "PresignedAuth_creds_invalid_date": PresignedAuth_creds_invalid_date, - "PresignedAuth_missing_date_query": PresignedAuth_missing_date_query, - "PresignedAuth_dates_mismatch": PresignedAuth_dates_mismatch, - "PresignedAuth_non_existing_access_key_id": PresignedAuth_non_existing_access_key_id, - "PresignedAuth_missing_signed_headers_query_param": PresignedAuth_missing_signed_headers_query_param, - "PresignedAuth_unsigned_required_header": PresignedAuth_unsigned_required_header, - "PresignedAuth_unsigned_non_required_header": PresignedAuth_unsigned_non_required_header, - "PresignedAuth_missing_expiration_query_param": PresignedAuth_missing_expiration_query_param, - "PresignedAuth_invalid_expiration_query_param": PresignedAuth_invalid_expiration_query_param, - "PresignedAuth_negative_expiration_query_param": PresignedAuth_negative_expiration_query_param, - "PresignedAuth_exceeding_expiration_query_param": PresignedAuth_exceeding_expiration_query_param, - "PresignedAuth_expired_request": PresignedAuth_expired_request, - "PresignedAuth_incorrect_secret_key": PresignedAuth_incorrect_secret_key, - "PresignedAuth_sigv2_not_supported": PresignedAuth_sigv2_not_supported, - "PresignedAuth_PutObject_success": PresignedAuth_PutObject_success, - "PutObject_missing_object_lock_retention_config": PutObject_missing_object_lock_retention_config, - "PutObject_name_too_long": PutObject_name_too_long, - "PutObject_with_object_lock": PutObject_with_object_lock, - "PutObject_missing_bucket_lock": PutObject_missing_bucket_lock, - "PutObject_invalid_legal_hold": PutObject_invalid_legal_hold, - "PutObject_invalid_object_lock_mode": PutObject_invalid_object_lock_mode, - "PutObject_past_retain_until_date": PutObject_past_retain_until_date, - "PutObject_invalid_retain_until_date": PutObject_invalid_retain_until_date, - "PutObject_conditional_writes": PutObject_conditional_writes, - "PutObject_should_combine_metadata": PutObject_should_combine_metadata, - "PutObject_md5": PutObject_md5, - "PutObject_long_metadata": PutObject_long_metadata, - "PutObject_with_metadata": PutObject_with_metadata, - "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, - "PutObject_invalid_credentials": PutObject_invalid_credentials, - "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, - "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, - "PutObject_invalid_checksum_header": PutObject_invalid_checksum_header, - "PutObject_incorrect_checksums": PutObject_incorrect_checksums, - "PutObject_default_checksum": PutObject_default_checksum, - "PutObject_dir_object_default_checksum": PutObject_dir_object_default_checksum, - "PutObject_checksums_success": PutObject_checksums_success, - "PutObject_dir_object_checksums_success": PutObject_dir_object_checksums_success, - "PresignedAuth_Put_GetObject_with_data": PresignedAuth_Put_GetObject_with_data, - "PresignedAuth_Put_GetObject_with_UTF8_chars": PresignedAuth_Put_GetObject_with_UTF8_chars, - "PresignedAuth_UploadPart": PresignedAuth_UploadPart, - "CreateBucket_invalid_bucket_name": CreateBucket_invalid_bucket_name, - "CreateBucket_existing_bucket": CreateBucket_existing_bucket, - "CreateBucket_owned_by_you": CreateBucket_owned_by_you, - "CreateBucket_invalid_ownership": CreateBucket_invalid_ownership, - "CreateBucket_ownership_with_acl": CreateBucket_ownership_with_acl, - "CreateBucket_as_user": CreateBucket_as_user, - "CreateBucket_success": CreateBucket_success, - "CreateBucket_default_acl": CreateBucket_default_acl, - "CreateBucket_non_default_acl": CreateBucket_non_default_acl, - "CreateBucket_private_canned_acl": CreateBucket_private_canned_acl, - "CreateBucket_private_canned_acl_bucket_owner_enforced_ownership": CreateBucket_private_canned_acl_bucket_owner_enforced_ownership, - "CreateBucket_default_object_lock": CreateBucket_default_object_lock, - "CreateBucket_invalid_location_constraint": CreateBucket_invalid_location_constraint, - "CreateBucket_long_tags": CreateBucket_long_tags, - "CreateBucket_invalid_tags": CreateBucket_invalid_tags, - "CreateBucket_duplicate_keys": CreateBucket_duplicate_keys, - "CreateBucket_tag_count_limit": CreateBucket_tag_count_limit, - "CreateBucket_invalid_canned_acl": CreateBucket_invalid_canned_acl, - "HeadBucket_non_existing_bucket": HeadBucket_non_existing_bucket, - "HeadBucket_success": HeadBucket_success, - "ListBuckets_as_user": ListBuckets_as_user, - "ListBuckets_as_admin": ListBuckets_as_admin, - "ListBuckets_with_prefix": ListBuckets_with_prefix, - "ListBuckets_invalid_max_buckets": ListBuckets_invalid_max_buckets, - "ListBuckets_truncated": ListBuckets_truncated, - "ListBuckets_success": ListBuckets_success, - "ListBuckets_empty_success": ListBuckets_empty_success, - "DeleteBucket_non_existing_bucket": DeleteBucket_non_existing_bucket, - "DeleteBucket_non_empty_bucket": DeleteBucket_non_empty_bucket, - "DeleteBucket_incorrect_expected_bucket_owner": DeleteBucket_incorrect_expected_bucket_owner, - "DeleteBucket_success_status_code": DeleteBucket_success_status_code, - "PutBucketOwnershipControls_non_existing_bucket": PutBucketOwnershipControls_non_existing_bucket, - "PutBucketOwnershipControls_multiple_rules": PutBucketOwnershipControls_multiple_rules, - "PutBucketOwnershipControls_invalid_ownership": PutBucketOwnershipControls_invalid_ownership, - "PutBucketOwnershipControls_empty_rules": PutBucketOwnershipControls_empty_rules, - "PutBucketOwnershipControls_success": PutBucketOwnershipControls_success, - "GetBucketOwnershipControls_non_existing_bucket": GetBucketOwnershipControls_non_existing_bucket, - "GetBucketOwnershipControls_default_ownership": GetBucketOwnershipControls_default_ownership, - "GetBucketOwnershipControls_success": GetBucketOwnershipControls_success, - "DeleteBucketOwnershipControls_non_existing_bucket": DeleteBucketOwnershipControls_non_existing_bucket, - "DeleteBucketOwnershipControls_success": DeleteBucketOwnershipControls_success, - "PutBucketTagging_non_existing_bucket": PutBucketTagging_non_existing_bucket, - "PutBucketTagging_long_tags": PutBucketTagging_long_tags, - "PutBucketTagging_invalid_tags": PutBucketTagging_invalid_tags, - "PutBucketTagging_duplicate_keys": PutBucketTagging_duplicate_keys, - "PutBucketTagging_tag_count_limit": PutBucketTagging_tag_count_limit, - "PutBucketTagging_success": PutBucketTagging_success, - "PutBucketTagging_success_status": PutBucketTagging_success_status, - "GetBucketTagging_non_existing_bucket": GetBucketTagging_non_existing_bucket, - "GetBucketTagging_unset_tags": GetBucketTagging_unset_tags, - "GetBucketTagging_success": GetBucketTagging_success, - "DeleteBucketTagging_non_existing_object": DeleteBucketTagging_non_existing_object, - "DeleteBucketTagging_success_status": DeleteBucketTagging_success_status, - "DeleteBucketTagging_success": DeleteBucketTagging_success, - "GetBucketLocation_success": GetBucketLocation_success, - "GetBucketLocation_non_exist": GetBucketLocation_non_exist, - "GetBucketLocation_no_access": GetBucketLocation_no_access, - "PutObject_non_existing_bucket": PutObject_non_existing_bucket, - "PutObject_special_chars": PutObject_special_chars, - "PutObject_tagging": PutObject_tagging, - "PutObject_success": PutObject_success, - "PutObject_default_content_type": PutObject_default_content_type, - "PutObject_invalid_object_names": PutObject_invalid_object_names, - "PutObject_object_acl_not_supported": PutObject_object_acl_not_supported, - "PutObject_false_negative_object_names": PutObject_false_negative_object_names, - "PutObject_racey_success": PutObject_racey_success, - "HeadObject_non_existing_object": HeadObject_non_existing_object, - "HeadObject_invalid_part_number": HeadObject_invalid_part_number, - "HeadObject_directory_object_noslash": HeadObject_directory_object_noslash, - "HeadObject_non_existing_dir_object": HeadObject_non_existing_dir_object, - "HeadObject_incidental_dir_object": HeadObject_incidental_dir_object, - "HeadObject_name_too_long": HeadObject_name_too_long, - "HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir, - "HeadObject_with_range": HeadObject_with_range, - "HeadObject_by_range_resp_status": HeadObject_by_range_resp_status, - "HeadObject_zero_len_with_range": HeadObject_zero_len_with_range, - "HeadObject_dir_with_range": HeadObject_dir_with_range, - "HeadObject_conditional_reads": HeadObject_conditional_reads, - "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, - "HeadObject_checksums": HeadObject_checksums, - "HeadObject_ranged_with_checksum_mode": HeadObject_ranged_with_checksum_mode, - "HeadObject_success": HeadObject_success, - "HeadObject_overrides_success": HeadObject_overrides_success, - "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, - "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, - "HeadObject_range_and_part_number": HeadObject_range_and_part_number, - "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, - "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, - "HeadObject_mp_part_number_resp_status": HeadObject_mp_part_number_resp_status, - "HeadObject_non_mp_part_number_1_success": HeadObject_non_mp_part_number_1_success, - "HeadObject_empty_object_part_number_1": HeadObject_empty_object_part_number_1, - "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, - "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, - "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs, - "GetObjectAttributes_invalid_parent": GetObjectAttributes_invalid_parent, - "GetObjectAttributes_invalid_single_attribute": GetObjectAttributes_invalid_single_attribute, - "GetObjectAttributes_empty_attrs": GetObjectAttributes_empty_attrs, - "GetObjectAttributes_existing_object": GetObjectAttributes_existing_object, - "GetObjectAttributes_checksums": GetObjectAttributes_checksums, - "GetObject_non_existing_key": GetObject_non_existing_key, - "GetObject_directory_object_noslash": GetObject_directory_object_noslash, - "GetObject_with_range": GetObject_with_range, - "GetObject_zero_len_with_range": GetObject_zero_len_with_range, - "GetObject_dir_with_range": GetObject_dir_with_range, - "GetObject_invalid_parent": GetObject_invalid_parent, - "GetObject_large_object": GetObject_large_object, - "GetObject_conditional_reads": GetObject_conditional_reads, - "GetObject_not_enabled_checksum_mode": GetObject_not_enabled_checksum_mode, - "GetObject_checksums": GetObject_checksums, - "GetObject_dir_object_checksum": GetObject_dir_object_checksum, - "GetObject_ranged_with_checksum_mode": GetObject_ranged_with_checksum_mode, - "GetObject_success": GetObject_success, - "GetObject_directory_success": GetObject_directory_success, - "GetObject_by_range_resp_status": GetObject_by_range_resp_status, - "GetObject_non_existing_dir_object": GetObject_non_existing_dir_object, - "GetObject_incidental_dir_object": GetObject_incidental_dir_object, - "GetObject_overrides_success": GetObject_overrides_success, - "GetObject_overrides_presign_success": GetObject_overrides_presign_success, - "GetObject_overrides_fail_public": GetObject_overrides_fail_public, - "GetObject_invalid_part_number": GetObject_invalid_part_number, - "GetObject_range_and_part_number": GetObject_range_and_part_number, - "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, - "GetObject_mp_part_number_success": GetObject_mp_part_number_success, - "GetObject_mp_part_number_resp_status": GetObject_mp_part_number_resp_status, - "GetObject_non_mp_part_number_1_success": GetObject_non_mp_part_number_1_success, - "GetObject_empty_object_part_number_1": GetObject_empty_object_part_number_1, - "ListObjects_non_existing_bucket": ListObjects_non_existing_bucket, - "ListObjects_with_prefix": ListObjects_with_prefix, - "ListObjects_truncated": ListObjects_truncated, - "ListObjects_paginated": ListObjects_paginated, - "ListObjects_invalid_max_keys": ListObjects_invalid_max_keys, - "ListObjects_max_keys_0": ListObjects_max_keys_0, - "ListObjects_delimiter": ListObjects_delimiter, - "ListObjects_max_keys_none": ListObjects_max_keys_none, - "ListObjects_marker_not_from_obj_list": ListObjects_marker_not_from_obj_list, - "ListObjects_list_all_objs": ListObjects_list_all_objs, - "ListObjects_nested_dir_file_objs": ListObjects_nested_dir_file_objs, - "ListObjects_check_owner": ListObjects_check_owner, - "ListObjects_non_truncated_common_prefixes": ListObjects_non_truncated_common_prefixes, - "ListObjects_should_not_list_pending_mps": ListObjects_should_not_list_pending_mps, - "ListObjects_mp_masking_with_marker": ListObjects_mp_masking_with_marker, - "ListObjects_mp_masking_truncation": ListObjects_mp_masking_truncation, - "ListObjects_mp_masking_delimiter": ListObjects_mp_masking_delimiter, - "ListObjectsV2_non_truncated_common_prefixes": ListObjectsV2_non_truncated_common_prefixes, - "ListObjectsV2_invalid_parent_prefix": ListObjectsV2_invalid_parent_prefix, - "ListObjectsV2_should_not_list_pending_mps": ListObjectsV2_should_not_list_pending_mps, - "ListObjectsV2_mp_masking_start_after": ListObjectsV2_mp_masking_start_after, - "ListObjectsV2_mp_masking_truncation": ListObjectsV2_mp_masking_truncation, - "ListObjectsV2_mp_masking_delimiter": ListObjectsV2_mp_masking_delimiter, - "ListObjects_with_checksum": ListObjects_with_checksum, - "ListObjectsV2_start_after": ListObjectsV2_start_after, - "ListObjectsV2_both_start_after_and_continuation_token": ListObjectsV2_both_start_after_and_continuation_token, - "ListObjectsV2_start_after_not_in_list": ListObjectsV2_start_after_not_in_list, - "ListObjectsV2_start_after_empty_result": ListObjectsV2_start_after_empty_result, - "ListObjectsV2_both_delimiter_and_prefix": ListObjectsV2_both_delimiter_and_prefix, - "ListObjectsV2_single_dir_object_with_delim_and_prefix": ListObjectsV2_single_dir_object_with_delim_and_prefix, - "ListObjectsV2_truncated_common_prefixes": ListObjectsV2_truncated_common_prefixes, - "ListObjectsV2_all_objs_max_keys": ListObjectsV2_all_objs_max_keys, - "ListObjectsV2_list_all_objs": ListObjectsV2_list_all_objs, - "ListObjectsV2_with_owner": ListObjectsV2_with_owner, - "ListObjectsV2_with_checksum": ListObjectsV2_with_checksum, - "ListObjectVersions_VD_success": ListObjectVersions_VD_success, - "DeleteObject_non_existing_object": DeleteObject_non_existing_object, - "DeleteObject_directory_object_noslash": DeleteObject_directory_object_noslash, - "DeleteObject_non_empty_dir_obj": DeleteObject_non_empty_dir_obj, - "DeleteObject_conditional_writes": DeleteObject_conditional_writes, - "DeleteObject_name_too_long": DeleteObject_name_too_long, - "CopyObject_overwrite_same_dir_object": CopyObject_overwrite_same_dir_object, - "CopyObject_overwrite_same_file_object": CopyObject_overwrite_same_file_object, - "DeleteObject_non_existing_dir_object": DeleteObject_non_existing_dir_object, - "DeleteObject_directory_object": DeleteObject_directory_object, - "DeleteObject_success": DeleteObject_success, - "DeleteObject_success_status_code": DeleteObject_success_status_code, - "DeleteObject_incorrect_expected_bucket_owner": DeleteObject_incorrect_expected_bucket_owner, - "DeleteObject_expected_bucket_owner": DeleteObject_expected_bucket_owner, - "DeleteObjects_empty_input": DeleteObjects_empty_input, - "DeleteObjects_non_existing_objects": DeleteObjects_non_existing_objects, - "DeleteObjects_success": DeleteObjects_success, - "CopyObject_non_existing_dst_bucket": CopyObject_non_existing_dst_bucket, - "CopyObject_not_owned_source_bucket": CopyObject_not_owned_source_bucket, - "CopyObject_copy_to_itself": CopyObject_copy_to_itself, - "CopyObject_copy_to_itself_invalid_directive": CopyObject_copy_to_itself_invalid_directive, - "CopyObject_should_replace_tagging": CopyObject_should_replace_tagging, - "CopyObject_should_copy_tagging": CopyObject_should_copy_tagging, - "CopyObject_invalid_tagging_directive": CopyObject_invalid_tagging_directive, - "CopyObject_long_metadata": CopyObject_long_metadata, - "CopyObject_to_itself_with_new_metadata": CopyObject_to_itself_with_new_metadata, - "CopyObject_copy_source_starting_with_slash": CopyObject_copy_source_starting_with_slash, - "CopyObject_invalid_copy_source": CopyObject_invalid_copy_source, - "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, - "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, - "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, - "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, - "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, - "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, - "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, - "CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode, - "CopyObject_with_legal_hold": CopyObject_with_legal_hold, - "CopyObject_with_retention_lock": CopyObject_with_retention_lock, - "CopyObject_conditional_reads": CopyObject_conditional_reads, - "CopyObject_object_acl_not_supported": CopyObject_object_acl_not_supported, - "CopyObject_with_metadata": CopyObject_with_metadata, - "CopyObject_invalid_checksum_algorithm": CopyObject_invalid_checksum_algorithm, - "CopyObject_create_checksum_on_copy": CopyObject_create_checksum_on_copy, - "CopyObject_should_copy_the_existing_checksum": CopyObject_should_copy_the_existing_checksum, - "CopyObject_should_replace_the_existing_checksum": CopyObject_should_replace_the_existing_checksum, - "CopyObject_to_itself_by_replacing_the_checksum": CopyObject_to_itself_by_replacing_the_checksum, - "CopyObject_with_special_characters": CopyObject_with_special_characters, - "CopyObject_success": CopyObject_success, - "CopyObject_incorrect_source_bucket_expected_owner": CopyObject_incorrect_source_bucket_expected_owner, - "PutObjectTagging_non_existing_object": PutObjectTagging_non_existing_object, - "PutObjectTagging_long_tags": PutObjectTagging_long_tags, - "PutObjectTagging_duplicate_keys": PutObjectTagging_duplicate_keys, - "PutObjectTagging_tag_count_limit": PutObjectTagging_tag_count_limit, - "PutObjectTagging_invalid_tags": PutObjectTagging_invalid_tags, - "PutObjectTagging_success": PutObjectTagging_success, - "GetObjectTagging_non_existing_object": GetObjectTagging_non_existing_object, - "GetObjectTagging_unset_tags": GetObjectTagging_unset_tags, - "GetObjectTagging_invalid_parent": GetObjectTagging_invalid_parent, - "GetObjectTagging_success": GetObjectTagging_success, - "DeleteObjectTagging_non_existing_object": DeleteObjectTagging_non_existing_object, - "DeleteObjectTagging_success_status": DeleteObjectTagging_success_status, - "DeleteObjectTagging_success": DeleteObjectTagging_success, - "DeleteObjectTagging_expected_bucket_owner": DeleteObjectTagging_expected_bucket_owner, - "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, - "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, - "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, - "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, - "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, - "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, - "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, - "CreateMultipartUpload_with_object_lock_invalid_retention": CreateMultipartUpload_with_object_lock_invalid_retention, - "CreateMultipartUpload_past_retain_until_date": CreateMultipartUpload_past_retain_until_date, - "CreateMultipartUpload_invalid_legal_hold": CreateMultipartUpload_invalid_legal_hold, - "CreateMultipartUpload_invalid_object_lock_mode": CreateMultipartUpload_invalid_object_lock_mode, - "CreateMultipartUpload_object_acl_not_supported": CreateMultipartUpload_object_acl_not_supported, - "CreateMultipartUpload_invalid_checksum_algorithm": CreateMultipartUpload_invalid_checksum_algorithm, - "CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type": CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type, - "CreateMultipartUpload_type_algo_mismatch": CreateMultipartUpload_type_algo_mismatch, - "CreateMultipartUpload_invalid_checksum_type": CreateMultipartUpload_invalid_checksum_type, - "CreateMultipartUpload_valid_algo_type": CreateMultipartUpload_valid_algo_type, - "CreateMultipartUpload_success": CreateMultipartUpload_success, - "UploadPart_non_existing_bucket": UploadPart_non_existing_bucket, - "UploadPart_invalid_part_number": UploadPart_invalid_part_number, - "UploadPart_non_existing_key": UploadPart_non_existing_key, - "UploadPart_non_existing_mp_upload": UploadPart_non_existing_mp_upload, - "UploadPart_multiple_checksum_headers": UploadPart_multiple_checksum_headers, - "UploadPart_invalid_checksum_header": UploadPart_invalid_checksum_header, - "UploadPart_checksum_header_and_algo_mismatch": UploadPart_checksum_header_and_algo_mismatch, - "UploadPart_checksum_algorithm_mistmatch_on_initialization": UploadPart_checksum_algorithm_mistmatch_on_initialization, - "UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value": UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value, - "UploadPart_incorrect_checksums": UploadPart_incorrect_checksums, - "UploadPart_no_checksum_with_full_object_checksum_type": UploadPart_no_checksum_with_full_object_checksum_type, - "UploadPart_no_checksum_with_composite_checksum_type": UploadPart_no_checksum_with_composite_checksum_type, - "UploadPart_with_checksums_success": UploadPart_with_checksums_success, - "UploadPart_success": UploadPart_success, - "UploadPartCopy_non_existing_bucket": UploadPartCopy_non_existing_bucket, - "UploadPartCopy_incorrect_uploadId": UploadPartCopy_incorrect_uploadId, - "UploadPartCopy_incorrect_object_key": UploadPartCopy_incorrect_object_key, - "UploadPartCopy_invalid_part_number": UploadPartCopy_invalid_part_number, - "UploadPartCopy_invalid_copy_source": UploadPartCopy_invalid_copy_source, - "UploadPartCopy_non_existing_source_bucket": UploadPartCopy_non_existing_source_bucket, - "UploadPartCopy_non_existing_source_object_key": UploadPartCopy_non_existing_source_object_key, - "UploadPartCopy_success": UploadPartCopy_success, - "UploadPartCopy_by_range_invalid_ranges": UploadPartCopy_by_range_invalid_ranges, - "UploadPartCopy_exceeding_copy_source_range": UploadPartCopy_exceeding_copy_source_range, - "UploadPartCopy_greater_range_than_obj_size": UploadPartCopy_greater_range_than_obj_size, - "UploadPartCopy_by_range_success": UploadPartCopy_by_range_success, - "UploadPartCopy_conditional_reads": UploadPartCopy_conditional_reads, - "UploadPartCopy_incorrect_source_bucket_expected_owner": UploadPartCopy_incorrect_source_bucket_expected_owner, - "UploadPartCopy_should_copy_the_checksum": UploadPartCopy_should_copy_the_checksum, - "UploadPartCopy_should_not_copy_the_checksum": UploadPartCopy_should_not_copy_the_checksum, - "UploadPartCopy_should_calculate_the_checksum": UploadPartCopy_should_calculate_the_checksum, - "ListParts_incorrect_uploadId": ListParts_incorrect_uploadId, - "ListParts_incorrect_object_key": ListParts_incorrect_object_key, - "ListParts_invalid_max_parts": ListParts_invalid_max_parts, - "ListParts_invalid_part_number_marker": ListParts_invalid_part_number_marker, - "ListParts_default_max_parts": ListParts_default_max_parts, - "ListParts_truncated": ListParts_truncated, - "ListParts_with_checksums": ListParts_with_checksums, - "ListParts_null_checksums": ListParts_null_checksums, - "ListParts_success": ListParts_success, - "ListMultipartUploads_non_existing_bucket": ListMultipartUploads_non_existing_bucket, - "ListMultipartUploads_empty_result": ListMultipartUploads_empty_result, - "ListMultipartUploads_invalid_max_uploads": ListMultipartUploads_invalid_max_uploads, - "ListMultipartUploads_max_uploads": ListMultipartUploads_max_uploads, - "ListMultipartUploads_exceeding_max_uploads": ListMultipartUploads_exceeding_max_uploads, - "ListMultipartUploads_ignore_upload_id_marker": ListMultipartUploads_ignore_upload_id_marker, - "ListMultipartUploads_invalid_uploadId_marker": ListMultipartUploads_invalid_uploadId_marker, - "ListMultipartUploads_keyMarker_not_from_list": ListMultipartUploads_keyMarker_not_from_list, - "ListMultipartUploads_delimiter_truncated": ListMultipartUploads_delimiter_truncated, - "ListMultipartUploads_prefix": ListMultipartUploads_prefix, - "ListMultipartUploads_both_delimiter_and_prefix": ListMultipartUploads_both_delimiter_and_prefix, - "ListMultipartUploads_with_checksums": ListMultipartUploads_with_checksums, - "AbortMultipartUpload_non_existing_bucket": AbortMultipartUpload_non_existing_bucket, - "AbortMultipartUpload_incorrect_uploadId": AbortMultipartUpload_incorrect_uploadId, - "AbortMultipartUpload_incorrect_object_key": AbortMultipartUpload_incorrect_object_key, - "AbortMultipartUpload_success": AbortMultipartUpload_success, - "AbortMultipartUpload_success_status_code": AbortMultipartUpload_success_status_code, - "AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time, - "CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket, - "CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number, - "CompleteMultipartUpload_default_content_type": CompleteMultipartUpload_default_content_type, - "CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag, - "CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size, - "CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts, - "CompleteMultipartUpload_missing_part_fields": CompleteMultipartUpload_missing_part_fields, - "CompleteMultipartUpload_incorrect_part_number": CompleteMultipartUpload_incorrect_part_number, - "CompleteMultipartUpload_incorrect_parts_order": CompleteMultipartUpload_incorrect_parts_order, - "CompleteMultipartUpload_mpu_object_size": CompleteMultipartUpload_mpu_object_size, - "CompleteMultipartUpload_conditional_writes": CompleteMultipartUpload_conditional_writes, - "CompleteMultipartUpload_with_metadata": CompleteMultipartUpload_with_metadata, - "CompleteMultipartUpload_invalid_checksum_type": CompleteMultipartUpload_invalid_checksum_type, - "CompleteMultipartUpload_invalid_checksum_part": CompleteMultipartUpload_invalid_checksum_part, - "CompleteMultipartUpload_multiple_checksum_part": CompleteMultipartUpload_multiple_checksum_part, - "CompleteMultipartUpload_incorrect_checksum_part": CompleteMultipartUpload_incorrect_checksum_part, - "CompleteMultipartUpload_different_checksum_part": CompleteMultipartUpload_different_checksum_part, - "CompleteMultipartUpload_missing_part_checksum": CompleteMultipartUpload_missing_part_checksum, - "CompleteMultipartUpload_multiple_final_checksums": CompleteMultipartUpload_multiple_final_checksums, - "CompleteMultipartUpload_invalid_final_checksums": CompleteMultipartUpload_invalid_final_checksums, - "CompleteMultipartUpload_incorrect_final_checksums": CompleteMultipartUpload_incorrect_final_checksums, - "CompleteMultipartUpload_should_calculate_the_final_checksum_full_object": CompleteMultipartUpload_should_calculate_the_final_checksum_full_object, - "CompleteMultipartUpload_should_verify_the_final_checksum": CompleteMultipartUpload_should_verify_the_final_checksum, - "CompleteMultipartUpload_should_verify_final_composite_checksum": CompleteMultipartUpload_should_verify_final_composite_checksum, - "CompleteMultipartUpload_invalid_final_composite_checksum": CompleteMultipartUpload_invalid_final_composite_checksum, - "CompleteMultipartUpload_checksum_type_mismatch": CompleteMultipartUpload_checksum_type_mismatch, - "CompleteMultipartUpload_should_ignore_the_final_checksum": CompleteMultipartUpload_should_ignore_the_final_checksum, - "CompleteMultipartUpload_should_succeed_without_final_checksum_type": CompleteMultipartUpload_should_succeed_without_final_checksum_type, - "CompleteMultipartUpload_success": CompleteMultipartUpload_success, - "CompleteMultipartUpload_already_completed": CompleteMultipartUpload_already_completed, - "CompleteMultipartUpload_racey_success": CompleteMultipartUpload_racey_success, - "CompleteMultipartUpload_racey_data_integrity": CompleteMultipartUpload_racey_data_integrity, - "PutBucketAcl_non_existing_bucket": PutBucketAcl_non_existing_bucket, - "PutBucketAcl_disabled": PutBucketAcl_disabled, - "PutBucketAcl_none_of_the_options_specified": PutBucketAcl_none_of_the_options_specified, - "PutBucketAcl_invalid_canned_acl": PutBucketAcl_invalid_canned_acl, - "PutBucketAcl_invalid_acl_canned_and_acp": PutBucketAcl_invalid_acl_canned_and_acp, - "PutBucketAcl_invalid_acl_canned_and_grants": PutBucketAcl_invalid_acl_canned_and_grants, - "PutBucketAcl_invalid_acl_acp_and_grants": PutBucketAcl_invalid_acl_acp_and_grants, - "PutBucketAcl_invalid_owner": PutBucketAcl_invalid_owner, - "PutBucketAcl_invalid_owner_not_in_body": PutBucketAcl_invalid_owner_not_in_body, - "PutBucketAcl_invalid_empty_owner_id_in_body": PutBucketAcl_invalid_empty_owner_id_in_body, - "PutBucketAcl_invalid_permission_in_body": PutBucketAcl_invalid_permission_in_body, - "PutBucketAcl_invalid_grantee_type_in_body": PutBucketAcl_invalid_grantee_type_in_body, - "PutBucketAcl_empty_grantee_ID_in_body": PutBucketAcl_empty_grantee_ID_in_body, - "PutBucketAcl_success_access_denied": PutBucketAcl_success_access_denied, - "PutBucketAcl_success_grants": PutBucketAcl_success_grants, - "PutBucketAcl_success_canned_acl": PutBucketAcl_success_canned_acl, - "PutBucketAcl_success_acp": PutBucketAcl_success_acp, - "GetBucketAcl_non_existing_bucket": GetBucketAcl_non_existing_bucket, - "GetBucketAcl_translation_canned_public_read": GetBucketAcl_translation_canned_public_read, - "GetBucketAcl_translation_canned_public_read_write": GetBucketAcl_translation_canned_public_read_write, - "GetBucketAcl_translation_canned_private": GetBucketAcl_translation_canned_private, - "GetBucketAcl_access_denied": GetBucketAcl_access_denied, - "GetBucketAcl_success": GetBucketAcl_success, - "PutBucketPolicy_non_existing_bucket": PutBucketPolicy_non_existing_bucket, - "PutBucketPolicy_invalid_json": PutBucketPolicy_invalid_json, - "PutBucketPolicy_statement_not_provided": PutBucketPolicy_statement_not_provided, - "PutBucketPolicy_empty_statement": PutBucketPolicy_empty_statement, - "PutBucketPolicy_invalid_effect": PutBucketPolicy_invalid_effect, - "PutBucketPolicy_invalid_action": PutBucketPolicy_invalid_action, - "PutBucketPolicy_empty_principals_string": PutBucketPolicy_empty_principals_string, - "PutBucketPolicy_empty_principals_array": PutBucketPolicy_empty_principals_array, - "PutBucketPolicy_principals_aws_struct_empty_string": PutBucketPolicy_principals_aws_struct_empty_string, - "PutBucketPolicy_principals_aws_struct_empty_string_slice": PutBucketPolicy_principals_aws_struct_empty_string_slice, - "PutBucketPolicy_principals_incorrect_wildcard_usage": PutBucketPolicy_principals_incorrect_wildcard_usage, - "PutBucketPolicy_non_existing_principals": PutBucketPolicy_non_existing_principals, - "PutBucketPolicy_empty_resources_string": PutBucketPolicy_empty_resources_string, - "PutBucketPolicy_empty_resources_array": PutBucketPolicy_empty_resources_array, - "PutBucketPolicy_invalid_resource_prefix": PutBucketPolicy_invalid_resource_prefix, - "PutBucketPolicy_invalid_resource_with_starting_slash": PutBucketPolicy_invalid_resource_with_starting_slash, - "PutBucketPolicy_duplicate_resource": PutBucketPolicy_duplicate_resource, - "PutBucketPolicy_incorrect_bucket_name": PutBucketPolicy_incorrect_bucket_name, - "PutBucketPolicy_action_resource_mismatch": PutBucketPolicy_action_resource_mismatch, - "PutBucketPolicy_explicit_deny": PutBucketPolicy_explicit_deny, - "PutBucketPolicy_multi_wildcard_resource": PutBucketPolicy_multi_wildcard_resource, - "PutBucketPolicy_any_char_match": PutBucketPolicy_any_char_match, - "PutBucketPolicy_version": PutBucketPolicy_version, - "PutBucketPolicy_success": PutBucketPolicy_success, - "PutBucketPolicy_status": PutBucketPolicy_status, - "GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket, - "GetBucketPolicy_not_set": GetBucketPolicy_not_set, - "GetBucketPolicy_success": GetBucketPolicy_success, - "GetBucketPolicyStatus_non_existing_bucket": GetBucketPolicyStatus_non_existing_bucket, - "GetBucketPolicyStatus_no_such_bucket_policy": GetBucketPolicyStatus_no_such_bucket_policy, - "GetBucketPolicyStatus_success": GetBucketPolicyStatus_success, - "DeleteBucketPolicy_non_existing_bucket": DeleteBucketPolicy_non_existing_bucket, - "DeleteBucketPolicy_remove_before_setting": DeleteBucketPolicy_remove_before_setting, - "DeleteBucketPolicy_success": DeleteBucketPolicy_success, - "PutBucketCors_non_existing_bucket": PutBucketCors_non_existing_bucket, - "PutBucketCors_empty_cors_rules": PutBucketCors_empty_cors_rules, - "PutBucketCors_invalid_allowed_origins": PutBucketCors_invalid_allowed_origins, - "PutBucketCors_invalid_method": PutBucketCors_invalid_method, - "PutBucketCors_invalid_header": PutBucketCors_invalid_header, - "PutBucketCors_md5": PutBucketCors_md5, - "GetBucketCors_non_existing_bucket": GetBucketCors_non_existing_bucket, - "GetBucketCors_no_such_bucket_cors": GetBucketCors_no_such_bucket_cors, - "GetBucketCors_success": GetBucketCors_success, - "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, - "DeleteBucketCors_success": DeleteBucketCors_success, - "PutBucketCors_success": PutBucketCors_success, - "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, - "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, - "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, - "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, - "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, - "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, - "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, - "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, - "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, - "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, - "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, - "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code, - "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code, - "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large, - "PutBucketWebsite_success": PutBucketWebsite_success, - "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, - "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, - "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, - "GetBucketWebsite_success": GetBucketWebsite_success, - "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, - "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, - "DeleteBucketWebsite_success": DeleteBucketWebsite_success, - "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, - "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, - "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, - "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, - "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, - "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, - "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, - "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, - "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, - "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, - "WebsiteHosting_object_redirect_location": WebsiteHosting_object_redirect_location, - "WebsiteHosting_index_document": WebsiteHosting_index_document, - "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, - "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, - "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers, - "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted, - "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden, - "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin, - "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, - "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, - "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, - "PreflightOPTIONS_invalid_request_headers": PreflightOPTIONS_invalid_request_headers, - "PreflightOPTIONS_unset_bucket_cors": PreflightOPTIONS_unset_bucket_cors, - "PreflightOPTIONS_access_forbidden": PreflightOPTIONS_access_forbidden, - "PreflightOPTIONS_access_granted": PreflightOPTIONS_access_granted, - "CORSMiddleware_invalid_method": CORSMiddleware_invalid_method, - "CORSMiddleware_invalid_headers": CORSMiddleware_invalid_headers, - "CORSMiddleware_access_forbidden": CORSMiddleware_access_forbidden, - "CORSMiddleware_access_granted": CORSMiddleware_access_granted, - "PutObjectLockConfiguration_non_existing_bucket": PutObjectLockConfiguration_non_existing_bucket, - "PutObjectLockConfiguration_empty_request_body": PutObjectLockConfiguration_empty_request_body, - "PutObjectLockConfiguration_malformed_body": PutObjectLockConfiguration_malformed_body, - "PutObjectLockConfiguration_not_enabled_on_bucket_creation": PutObjectLockConfiguration_not_enabled_on_bucket_creation, - "PutObjectLockConfiguration_invalid_status": PutObjectLockConfiguration_invalid_status, - "PutObjectLockConfiguration_invalid_mode": PutObjectLockConfiguration_invalid_mode, - "PutObjectLockConfiguration_both_years_and_days": PutObjectLockConfiguration_both_years_and_days, - "PutObjectLockConfiguration_invalid_years_days": PutObjectLockConfiguration_invalid_years_days, - "PutObjectLockConfiguration_success": PutObjectLockConfiguration_success, - "GetObjectLockConfiguration_non_existing_bucket": GetObjectLockConfiguration_non_existing_bucket, - "GetObjectLockConfiguration_unset_config": GetObjectLockConfiguration_unset_config, - "GetObjectLockConfiguration_success": GetObjectLockConfiguration_success, - "PutObjectRetention_non_existing_bucket": PutObjectRetention_non_existing_bucket, - "PutObjectRetention_non_existing_object": PutObjectRetention_non_existing_object, - "PutObjectRetention_unset_bucket_object_lock_config": PutObjectRetention_unset_bucket_object_lock_config, - "PutObjectRetention_expired_retain_until_date": PutObjectRetention_expired_retain_until_date, - "PutObjectRetention_invalid_mode": PutObjectRetention_invalid_mode, - "PutObjectRetention_overwrite_compliance_mode": PutObjectRetention_overwrite_compliance_mode, - "PutObjectRetention_overwrite_compliance_with_compliance": PutObjectRetention_overwrite_compliance_with_compliance, - "PutObjectRetention_overwrite_governance_with_governance": PutObjectRetention_overwrite_governance_with_governance, - "PutObjectRetention_overwrite_governance_without_bypass_specified": PutObjectRetention_overwrite_governance_without_bypass_specified, - "PutObjectRetention_overwrite_governance_with_permission": PutObjectRetention_overwrite_governance_with_permission, - "PutObjectRetention_success": PutObjectRetention_success, - "GetObjectRetention_non_existing_bucket": GetObjectRetention_non_existing_bucket, - "GetObjectRetention_non_existing_object": GetObjectRetention_non_existing_object, - "GetObjectRetention_disabled_lock": GetObjectRetention_disabled_lock, - "GetObjectRetention_unset_config": GetObjectRetention_unset_config, - "GetObjectRetention_success": GetObjectRetention_success, - "PutObjectLegalHold_non_existing_bucket": PutObjectLegalHold_non_existing_bucket, - "PutObjectLegalHold_non_existing_object": PutObjectLegalHold_non_existing_object, - "PutObjectLegalHold_invalid_body": PutObjectLegalHold_invalid_body, - "PutObjectLegalHold_invalid_status": PutObjectLegalHold_invalid_status, - "PutObjectLegalHold_unset_bucket_object_lock_config": PutObjectLegalHold_unset_bucket_object_lock_config, - "PutObjectLegalHold_success": PutObjectLegalHold_success, - "GetObjectLegalHold_non_existing_bucket": GetObjectLegalHold_non_existing_bucket, - "GetObjectLegalHold_non_existing_object": GetObjectLegalHold_non_existing_object, - "GetObjectLegalHold_disabled_lock": GetObjectLegalHold_disabled_lock, - "GetObjectLegalHold_unset_config": GetObjectLegalHold_unset_config, - "GetObjectLegalHold_success": GetObjectLegalHold_success, - "PutBucketAnalyticsConfiguration_not_implemented": PutBucketAnalyticsConfiguration_not_implemented, - "GetBucketAnalyticsConfiguration_not_implemented": GetBucketAnalyticsConfiguration_not_implemented, - "ListBucketAnalyticsConfiguration_not_implemented": ListBucketAnalyticsConfiguration_not_implemented, - "DeleteBucketAnalyticsConfiguration_not_implemented": DeleteBucketAnalyticsConfiguration_not_implemented, - "PutBucketEncryption_not_implemented": PutBucketEncryption_not_implemented, - "GetBucketEncryption_not_implemented": GetBucketEncryption_not_implemented, - "DeleteBucketEncryption_not_implemented": DeleteBucketEncryption_not_implemented, - "PutBucketIntelligentTieringConfiguration_not_implemented": PutBucketIntelligentTieringConfiguration_not_implemented, - "GetBucketIntelligentTieringConfiguration_not_implemented": GetBucketIntelligentTieringConfiguration_not_implemented, - "ListBucketIntelligentTieringConfiguration_not_implemented": ListBucketIntelligentTieringConfiguration_not_implemented, - "DeleteBucketIntelligentTieringConfiguration_not_implemented": DeleteBucketIntelligentTieringConfiguration_not_implemented, - "PutBucketInventoryConfiguration_not_implemented": PutBucketInventoryConfiguration_not_implemented, - "GetBucketInventoryConfiguration_not_implemented": GetBucketInventoryConfiguration_not_implemented, - "ListBucketInventoryConfiguration_not_implemented": ListBucketInventoryConfiguration_not_implemented, - "DeleteBucketInventoryConfiguration_not_implemented": DeleteBucketInventoryConfiguration_not_implemented, - "PutBucketLifecycleConfiguration_not_implemented": PutBucketLifecycleConfiguration_not_implemented, - "GetBucketLifecycleConfiguration_not_implemented": GetBucketLifecycleConfiguration_not_implemented, - "DeleteBucketLifecycle_not_implemented": DeleteBucketLifecycle_not_implemented, - "PutBucketLogging_not_implemented": PutBucketLogging_not_implemented, - "GetBucketLogging_not_implemented": GetBucketLogging_not_implemented, - "PutBucketRequestPayment_not_implemented": PutBucketRequestPayment_not_implemented, - "GetBucketRequestPayment_not_implemented": GetBucketRequestPayment_not_implemented, - "PutBucketMetricsConfiguration_not_implemented": PutBucketMetricsConfiguration_not_implemented, - "GetBucketMetricsConfiguration_not_implemented": GetBucketMetricsConfiguration_not_implemented, - "ListBucketMetricsConfigurations_not_implemented": ListBucketMetricsConfigurations_not_implemented, - "DeleteBucketMetricsConfiguration_not_implemented": DeleteBucketMetricsConfiguration_not_implemented, - "PutBucketReplication_not_implemented": PutBucketReplication_not_implemented, - "GetBucketReplication_not_implemented": GetBucketReplication_not_implemented, - "DeleteBucketReplication_not_implemented": DeleteBucketReplication_not_implemented, - "PutPublicAccessBlock_not_implemented": PutPublicAccessBlock_not_implemented, - "GetPublicAccessBlock_not_implemented": GetPublicAccessBlock_not_implemented, - "DeletePublicAccessBlock_not_implemented": DeletePublicAccessBlock_not_implemented, - "PutBucketNotificationConfiguratio_not_implemented": PutBucketNotificationConfiguratio_not_implemented, - "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, - "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, - "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, - "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, - "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, - "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, - "WORMProtection_bucket_object_lock_configuration_governance_mode": WORMProtection_bucket_object_lock_configuration_governance_mode, - "WORMProtection_bucket_object_lock_governance_bypass_delete": WORMProtection_bucket_object_lock_governance_bypass_delete, - "WORMProtection_bucket_object_lock_governance_bypass_delete_multiple": WORMProtection_bucket_object_lock_governance_bypass_delete_multiple, - "WORMProtection_object_lock_retention_compliance_locked": WORMProtection_object_lock_retention_compliance_locked, - "WORMProtection_object_lock_retention_governance_locked": WORMProtection_object_lock_retention_governance_locked, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_copy": WORMProtection_object_lock_retention_governance_bypass_overwrite_copy, - "WORMProtection_object_lock_retention_governance_bypass_overwrite_mp": WORMProtection_object_lock_retention_governance_bypass_overwrite_mp, - "WORMProtection_unable_to_overwrite_locked_object_put": WORMProtection_unable_to_overwrite_locked_object_put, - "WORMProtection_unable_to_overwrite_locked_object_copy": WORMProtection_unable_to_overwrite_locked_object_copy, - "WORMProtection_unable_to_overwrite_locked_object_mp": WORMProtection_unable_to_overwrite_locked_object_mp, - "WORMProtection_object_lock_retention_governance_bypass_delete": WORMProtection_object_lock_retention_governance_bypass_delete, - "WORMProtection_object_lock_retention_governance_bypass_delete_mul": WORMProtection_object_lock_retention_governance_bypass_delete_mul, - "WORMProtection_object_lock_legal_hold_locked": WORMProtection_object_lock_legal_hold_locked, - "WORMProtection_root_bypass_governance_retention_delete_object": WORMProtection_root_bypass_governance_retention_delete_object, - "PutObject_overwrite_dir_obj": PutObject_overwrite_dir_obj, - "PutObject_overwrite_file_obj": PutObject_overwrite_file_obj, - "PutObject_overwrite_file_obj_with_nested_obj": PutObject_overwrite_file_obj_with_nested_obj, - "PutObject_dir_obj_with_data": PutObject_dir_obj_with_data, - "PutObject_with_slashes": PutObject_with_slashes, - "PutObject_race_with_delete": PutObject_race_with_delete, - "CreateMultipartUpload_dir_obj": CreateMultipartUpload_dir_obj, - "IAM_user_access_denied": IAM_user_access_denied, - "IAM_userplus_access_denied": IAM_userplus_access_denied, - "IAM_userplus_CreateBucket": IAM_userplus_CreateBucket, - "IAM_admin_ChangeBucketOwner": IAM_admin_ChangeBucketOwner, - "IAM_ChangeBucketOwner_back_to_root": IAM_ChangeBucketOwner_back_to_root, - "IAM_ListBuckets": IAM_ListBuckets, - "IAM_CreateBucket_empty_owner_header": IAM_CreateBucket_empty_owner_header, - "IAM_CreateBucket_non_existing_user": IAM_CreateBucket_non_existing_user, - "IAM_CreateBucket_success": IAM_CreateBucket_success, - "AccessControl_default_ACL_user_access_denied": AccessControl_default_ACL_user_access_denied, - "AccessControl_default_ACL_userplus_access_denied": AccessControl_default_ACL_userplus_access_denied, - "AccessControl_default_ACL_admin_successful_access": AccessControl_default_ACL_admin_successful_access, - "AccessControl_bucket_resource_single_action": AccessControl_bucket_resource_single_action, - "AccessControl_bucket_resource_all_action": AccessControl_bucket_resource_all_action, - "AccessControl_single_object_resource_actions": AccessControl_single_object_resource_actions, - "AccessControl_multi_statement_policy": AccessControl_multi_statement_policy, - "AccessControl_bucket_ownership_to_user": AccessControl_bucket_ownership_to_user, - "AccessControl_root_PutBucketAcl": AccessControl_root_PutBucketAcl, - "AccessControl_user_PutBucketAcl_with_policy_access": AccessControl_user_PutBucketAcl_with_policy_access, - "AccessControl_copy_object_with_starting_slash_for_user": AccessControl_copy_object_with_starting_slash_for_user, - "AccessControl_PutObject_with_tagging_policy": AccessControl_PutObject_with_tagging_policy, - "AccessControl_PutObject_with_legal_hold_policy": AccessControl_PutObject_with_legal_hold_policy, - "AccessControl_PutObject_with_retention_policy": AccessControl_PutObject_with_retention_policy, - "AccessControl_CreateMultipartUpload_with_tagging_policy": AccessControl_CreateMultipartUpload_with_tagging_policy, - "AccessControl_CreateMultipartUpload_with_legal_hold_policy": AccessControl_CreateMultipartUpload_with_legal_hold_policy, - "AccessControl_CreateMultipartUpload_with_retention_policy": AccessControl_CreateMultipartUpload_with_retention_policy, - "AccessControl_CopyObject_with_tagging_policy": AccessControl_CopyObject_with_tagging_policy, - "AccessControl_CopyObject_with_legal_hold_policy": AccessControl_CopyObject_with_legal_hold_policy, - "AccessControl_CopyObject_with_retention_policy": AccessControl_CopyObject_with_retention_policy, - "AccessControl_policy_normalizes_object_key_for_get_put_delete": AccessControl_policy_normalizes_object_key_for_get_put_delete, - "PublicBucket_default_private_bucket": PublicBucket_default_private_bucket, - "PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy, - "PublicBucket_public_object_policy": PublicBucket_public_object_policy, - "PublicBucket_public_acl": PublicBucket_public_acl, - "PublicBucket_policy_deny_overrides_public_acl": PublicBucket_policy_deny_overrides_public_acl, - "PublicBucket_signed_streaming_payload": PublicBucket_signed_streaming_payload, - "PublicBucket_incorrect_sha256_hash": PublicBucket_incorrect_sha256_hash, - "PutBucketVersioning_non_existing_bucket": PutBucketVersioning_non_existing_bucket, - "PutBucketVersioning_invalid_status": PutBucketVersioning_invalid_status, - "PutBucketVersioning_success_enabled": PutBucketVersioning_success_enabled, - "PutBucketVersioning_success_suspended": PutBucketVersioning_success_suspended, - "GetBucketVersioning_non_existing_bucket": GetBucketVersioning_non_existing_bucket, - "GetBucketVersioning_empty_response": GetBucketVersioning_empty_response, - "GetBucketVersioning_success": GetBucketVersioning_success, - "Versioning_DeleteBucket_not_empty": Versioning_DeleteBucket_not_empty, - "Versioning_PutObject_suspended_null_versionId_obj": Versioning_PutObject_suspended_null_versionId_obj, - "Versioning_PutObject_null_versionId_obj": Versioning_PutObject_null_versionId_obj, - "Versioning_PutObject_overwrite_null_versionId_obj": Versioning_PutObject_overwrite_null_versionId_obj, - "Versioning_PutObject_success": Versioning_PutObject_success, - "Versioning_CopyObject_invalid_versionId": Versioning_CopyObject_invalid_versionId, - "Versioning_CopyObject_success": Versioning_CopyObject_success, - "Versioning_CopyObject_non_existing_version_id": Versioning_CopyObject_non_existing_version_id, - "Versioning_CopyObject_from_an_object_version": Versioning_CopyObject_from_an_object_version, - "Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars, - "Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId, - "Versioning_HeadObject_non_existing_object_version": Versioning_HeadObject_non_existing_object_version, - "Versioning_HeadObject_invalid_parent": Versioning_HeadObject_invalid_parent, - "Versioning_HeadObject_success": Versioning_HeadObject_success, - "Versioning_HeadObject_without_versionId": Versioning_HeadObject_without_versionId, - "Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker, - "Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId, - "Versioning_GetObject_non_existing_object_version": Versioning_GetObject_non_existing_object_version, - "Versioning_GetObject_success": Versioning_GetObject_success, - "Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId, - "Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker, - "Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj, - "Versioning_PutObjectTagging_invalid_versionId": Versioning_PutObjectTagging_invalid_versionId, - "Versioning_PutObjectTagging_non_existing_object_version": Versioning_PutObjectTagging_non_existing_object_version, - "Versioning_PutGetDeleteObjectTagging_delete_marker": Versioning_PutGetDeleteObjectTagging_delete_marker, - "Versioning_GetObjectTagging_invalid_versionId": Versioning_GetObjectTagging_invalid_versionId, - "Versioning_GetObjectTagging_non_existing_object_version": Versioning_GetObjectTagging_non_existing_object_version, - "Versioning_DeleteObjectTagging_invalid_versionId": Versioning_DeleteObjectTagging_invalid_versionId, - "Versioning_DeleteObjectTagging_non_existing_object_version": Versioning_DeleteObjectTagging_non_existing_object_version, - "Versioning_PutGetDeleteObjectTagging_success": Versioning_PutGetDeleteObjectTagging_success, - "Versioning_GetObjectAttributes_invalid_versionId": Versioning_GetObjectAttributes_invalid_versionId, - "Versioning_GetObjectAttributes_object_version": Versioning_GetObjectAttributes_object_version, - "Versioning_GetObjectAttributes_delete_marker": Versioning_GetObjectAttributes_delete_marker, - "Versioning_DeleteObject_invalid_versionId": Versioning_DeleteObject_invalid_versionId, - "Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version, - "Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object, - "Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker, - "Versioning_Delete_null_versionId_object": Versioning_Delete_null_versionId_object, - "Versioning_DeleteObject_nested_dir_object": Versioning_DeleteObject_nested_dir_object, - "Versioning_DeleteObject_non_existing_objects": Versioning_DeleteObject_non_existing_objects, - "Versioning_DeleteObject_suspended": Versioning_DeleteObject_suspended, - "Versioning_DeleteObjects_success": Versioning_DeleteObjects_success, - "Versioning_DeleteObjects_delete_deleteMarkers": Versioning_DeleteObjects_delete_deleteMarkers, - "ListObjectVersions_non_existing_bucket": ListObjectVersions_non_existing_bucket, - "ListObjectVersions_negative_max_keys": ListObjectVersions_negative_max_keys, - "ListObjectVersions_list_single_object_versions": ListObjectVersions_list_single_object_versions, - "ListObjectVersions_list_multiple_object_versions": ListObjectVersions_list_multiple_object_versions, - "ListObjectVersions_multiple_object_versions_truncated": ListObjectVersions_multiple_object_versions_truncated, - "ListObjectVersions_with_delete_markers": ListObjectVersions_with_delete_markers, - "ListObjectVersions_containing_null_versionId_obj": ListObjectVersions_containing_null_versionId_obj, - "ListObjectVersions_single_null_versionId_object": ListObjectVersions_single_null_versionId_object, - "ListObjectVersions_checksum": ListObjectVersions_checksum, - "Versioning_Multipart_Upload_success": Versioning_Multipart_Upload_success, - "Versioning_Multipart_Upload_overwrite_an_object": Versioning_Multipart_Upload_overwrite_an_object, - "Versioning_UploadPartCopy_invalid_versionId": Versioning_UploadPartCopy_invalid_versionId, - "Versioning_UploadPartCopy_non_existing_versionId": Versioning_UploadPartCopy_non_existing_versionId, - "Versioning_UploadPartCopy_from_an_object_version": Versioning_UploadPartCopy_from_an_object_version, - "Versioning_object_lock_not_enabled_on_bucket_creation": Versioning_object_lock_not_enabled_on_bucket_creation, - "Versioning_Enable_object_lock": Versioning_Enable_object_lock, - "Versioning_status_switch_to_suspended_with_object_lock": Versioning_status_switch_to_suspended_with_object_lock, - "Versioning_PutObjectRetention_invalid_versionId": Versioning_PutObjectRetention_invalid_versionId, - "Versioning_PutObjectRetention_non_existing_object_version": Versioning_PutObjectRetention_non_existing_object_version, - "Versioning_GetObjectRetention_invalid_versionId": Versioning_GetObjectRetention_invalid_versionId, - "Versioning_GetObjectRetention_non_existing_object_version": Versioning_GetObjectRetention_non_existing_object_version, - "Versioning_Put_GetObjectRetention_delete_marker": Versioning_Put_GetObjectRetention_delete_marker, - "Versioning_Put_GetObjectRetention_success": Versioning_Put_GetObjectRetention_success, - "Versioning_PutObjectLegalHold_invalid_versionId": Versioning_PutObjectLegalHold_invalid_versionId, - "Versioning_PutObjectLegalHold_non_existing_object_version": Versioning_PutObjectLegalHold_non_existing_object_version, - "Versioning_GetObjectLegalHold_invalid_versionId": Versioning_GetObjectLegalHold_invalid_versionId, - "Versioning_GetObjectLegalHold_non_existing_object_version": Versioning_GetObjectLegalHold_non_existing_object_version, - "Versioning_PutGetObjectLegalHold_delete_marker": Versioning_PutGetObjectLegalHold_delete_marker, - "Versioning_Put_GetObjectLegalHold_success": Versioning_Put_GetObjectLegalHold_success, - "Versioning_WORM_obj_version_locked_with_legal_hold": Versioning_WORM_obj_version_locked_with_legal_hold, - "Versioning_WORM_obj_version_locked_with_governance_retention": Versioning_WORM_obj_version_locked_with_governance_retention, - "Versioning_WORM_obj_version_locked_with_compliance_retention": Versioning_WORM_obj_version_locked_with_compliance_retention, - "Versioning_WORM_delete_marker_locked_object_legal_hold": Versioning_WORM_delete_marker_locked_object_legal_hold, - "Versioning_WORM_delete_marker_locked_object_governance_retention": Versioning_WORM_delete_marker_locked_object_governance_retention, - "Versioning_WORM_delete_marker_locked_object_compliance_retention": Versioning_WORM_delete_marker_locked_object_compliance_retention, - "Versioning_WORM_PutObject_overwrite_locked_object": Versioning_WORM_PutObject_overwrite_locked_object, - "Versioning_WORM_CopyObject_overwrite_locked_object": Versioning_WORM_CopyObject_overwrite_locked_object, - "Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object": Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object, - "Versioning_WORM_remove_delete_marker_under_bucket_default_retention": Versioning_WORM_remove_delete_marker_under_bucket_default_retention, - "Versioning_AccessControl_GetObjectVersion": Versioning_AccessControl_GetObjectVersion, - "Versioning_AccessControl_HeadObjectVersion": Versioning_AccessControl_HeadObjectVersion, - "Versioning_AccessControl_object_tagging_policy": Versioning_AccessControl_object_tagging_policy, - "Versioning_AccessControl_DeleteObject_policy": Versioning_AccessControl_DeleteObject_policy, - "Versioning_AccessControl_GetObjectAttributes_policy": Versioning_AccessControl_GetObjectAttributes_policy, - "Versioning_concurrent_upload_object": Versioning_concurrent_upload_object, - "RouterPutPartNumberWithoutUploadId": RouterPutPartNumberWithoutUploadId, - "RouterPostRoot": RouterPostRoot, - "RouterPostObjectWithoutQuery": RouterPostObjectWithoutQuery, - "RouterPUTObjectOnlyUploadId": RouterPUTObjectOnlyUploadId, - "RouterGetUploadsWithKey": RouterGetUploadsWithKey, - "RouterCopySourceNotAllowed": RouterCopySourceNotAllowed, - "RouterListVersionsWithKey": RouterListVersionsWithKey, - "UnsignedStreaminPayloadTrailer_malformed_trailer": UnsignedStreaminPayloadTrailer_malformed_trailer, - "UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length": UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length, - "UnsignedStreamingPayloadTrailer_invalid_trailing_checksum": UnsignedStreamingPayloadTrailer_invalid_trailing_checksum, - "UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum": UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum, - "UnsignedStreamingPayloadTrailer_multiple_checksum_headers": UnsignedStreamingPayloadTrailer_multiple_checksum_headers, - "UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch": UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch, - "UnsignedStreamingPayloadTrailer_incomplete_body": UnsignedStreamingPayloadTrailer_incomplete_body, - "UnsignedStreamingPayloadTrailer_invalid_chunk_size": UnsignedStreamingPayloadTrailer_invalid_chunk_size, - "UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch": UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch, - "UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme": UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme, - "UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers": UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers, - "UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer": UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer, - "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum, - "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object, - "UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch": UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch, - "UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer": UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer, - "UnsignedStreamingPayloadTrailer_not_allowed": UnsignedStreamingPayloadTrailer_not_allowed, - "SignedStreamingPayload_invalid_encoding": SignedStreamingPayload_invalid_encoding, - "SignedStreamingPayload_invalid_chunk_size": SignedStreamingPayload_invalid_chunk_size, - "SignedStreamingPayload_decoded_content_length_mismatch": SignedStreamingPayload_decoded_content_length_mismatch, - "SignedStreamingPayloadTrailer_malformed_trailer": SignedStreamingPayloadTrailer_malformed_trailer, - "SignedStreamingPayloadTrailer_incomplete_body": SignedStreamingPayloadTrailer_incomplete_body, - "SignedStreamingPayloadTrailer_missing_x_amz_trailer_header": SignedStreamingPayloadTrailer_missing_x_amz_trailer_header, - "SignedStreamingPayloadTrailer_invalid_checksum": SignedStreamingPayloadTrailer_invalid_checksum, - "SignedStreamingPayloadTrailer_bad_digest": SignedStreamingPayloadTrailer_bad_digest, - "SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success, - "NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl, - "NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl, - "Server_large_http_header": Server_large_http_header, - "PostObject_invalid_content_type": PostObject_invalid_content_type, - "PostObject_missing_boundary": PostObject_missing_boundary, - "PostObject_partial_auth_fields": PostObject_partial_auth_fields, - "PostObject_invalid_algorithm": PostObject_invalid_algorithm, - "PostObject_invalid_date": PostObject_invalid_date, - "PostObject_invalid_credential_format": PostObject_invalid_credential_format, - "PostObject_incorrect_region": PostObject_incorrect_region, - "PostObject_non_existing_access_key": PostObject_non_existing_access_key, - "PostObject_signature_mismatch": PostObject_signature_mismatch, - "PostObject_expired_due_to_date": PostObject_expired_due_to_date, - "PostObject_access_denied": PostObject_access_denied, - "PostObject_invalid_object_names": PostObject_invalid_object_names, - "PostObject_policy_access_control": PostObject_policy_access_control, - "PostObject_policy_expired": PostObject_policy_expired, - "PostObject_invalid_policy_document": PostObject_invalid_policy_document, - "PostObject_policy_condition_key_mismatch": PostObject_policy_condition_key_mismatch, - "PostObject_policy_extra_field": PostObject_policy_extra_field, - "PostObject_policy_missing_bucket_condition": PostObject_policy_missing_bucket_condition, - "PostObject_policy_content_length_too_large": PostObject_policy_content_length_too_large, - "PostObject_policy_content_length_too_small": PostObject_policy_content_length_too_small, - "PostObject_success": PostObject_success, - "PostObject_success_status_200": PostObject_success_status_200, - "PostObject_success_status_201": PostObject_success_status_201, - "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, - "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, - "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, - "PostObject_invalid_tagging": PostObject_invalid_tagging, - "PostObject_success_with_tagging": PostObject_success_with_tagging, - "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, - "PostObject_invalid_checksum_algorithm": PostObject_invalid_checksum_algorithm, - "PostObject_multiple_checksum_headers": PostObject_multiple_checksum_headers, - "PostObject_checksums_success": PostObject_checksums_success, - "PostObject_success_double_dash_boundary": PostObject_success_double_dash_boundary, + "Authentication_invalid_auth_header": Authentication_invalid_auth_header, + "Authentication_unsupported_signature_version": Authentication_unsupported_signature_version, + "Authentication_missing_components": Authentication_missing_components, + "Authentication_malformed_component": Authentication_malformed_component, + "Authentication_missing_credentials": Authentication_missing_credentials, + "Authentication_missing_signedheaders": Authentication_missing_signedheaders, + "Authentication_missing_signature": Authentication_missing_signature, + "Authentication_malformed_credential": Authentication_malformed_credential, + "Authentication_credentials_invalid_terminal": Authentication_credentials_invalid_terminal, + "Authentication_credentials_incorrect_service": Authentication_credentials_incorrect_service, + "Authentication_credentials_incorrect_region": Authentication_credentials_incorrect_region, + "Authentication_credentials_invalid_date": Authentication_credentials_invalid_date, + "Authentication_credentials_future_date": Authentication_credentials_future_date, + "Authentication_credentials_past_date": Authentication_credentials_past_date, + "Authentication_credentials_non_existing_access_key": Authentication_credentials_non_existing_access_key, + "Authentication_missing_date_header": Authentication_missing_date_header, + "Authentication_invalid_date_header": Authentication_invalid_date_header, + "Authentication_date_mismatch": Authentication_date_mismatch, + "Authentication_incorrect_payload_hash": Authentication_incorrect_payload_hash, + "Authentication_invalid_sha256_payload_hash": Authentication_invalid_sha256_payload_hash, + "Authentication_unsigned_required_header": Authentication_unsigned_required_header, + "Authentication_unsigned_non_required_header": Authentication_unsigned_non_required_header, + "Authentication_signature_error_incorrect_secret_key": Authentication_signature_error_incorrect_secret_key, + "Authentication_sigv2_not_supported": Authentication_sigv2_not_supported, + "Authentication_with_expect_header": Authentication_with_expect_header, + "IAMAuth_invalid_auth_header": IAMAuth_invalid_auth_header, + "IAMAuth_unsupported_signature_version": IAMAuth_unsupported_signature_version, + "IAMAuth_malformed_component": IAMAuth_malformed_component, + "IAMAuth_missing_authorization_component": IAMAuth_missing_authorization_component, + "IAMAuth_malformed_credential": IAMAuth_malformed_credential, + "IAMAuth_credentials_invalid_terminal": IAMAuth_credentials_invalid_terminal, + "IAMAuth_credentials_incorrect_service": IAMAuth_credentials_incorrect_service, + "IAMAuth_credentials_incorrect_region": IAMAuth_credentials_incorrect_region, + "IAMAuth_credentials_invalid_date": IAMAuth_credentials_invalid_date, + "IAMAuth_credentials_future_date": IAMAuth_credentials_future_date, + "IAMAuth_credentials_past_date": IAMAuth_credentials_past_date, + "IAMAuth_credentials_non_existing_access_key": IAMAuth_credentials_non_existing_access_key, + "IAMAuth_missing_date_header": IAMAuth_missing_date_header, + "IAMAuth_invalid_date_header": IAMAuth_invalid_date_header, + "IAMAuth_date_mismatch": IAMAuth_date_mismatch, + "IAMAuth_invalid_sha256_payload_hash_ignored": IAMAuth_invalid_sha256_payload_hash_ignored, + "IAMAuth_unsigned_required_header": IAMAuth_unsigned_required_header, + "IAMAuth_unsigned_non_required_header": IAMAuth_unsigned_non_required_header, + "IAMAuth_signature_error_incorrect_secret_key": IAMAuth_signature_error_incorrect_secret_key, + "IAMAuth_sigv2_not_supported": IAMAuth_sigv2_not_supported, + "IAMAuth_with_expect_header": IAMAuth_with_expect_header, + "IAMQueryAuth_success": IAMQueryAuth_success, + "IAMQueryAuth_security_token_not_supported": IAMQueryAuth_security_token_not_supported, + "IAMQueryAuth_unsupported_algorithm": IAMQueryAuth_unsupported_algorithm, + "IAMQueryAuth_ECDSA_not_supported": IAMQueryAuth_ECDSA_not_supported, + "IAMQueryAuth_missing_query_parameters": IAMQueryAuth_missing_query_parameters, + "IAMQueryAuth_malformed_credential": IAMQueryAuth_malformed_credential, + "IAMQueryAuth_credentials_invalid_terminal": IAMQueryAuth_credentials_invalid_terminal, + "IAMQueryAuth_credentials_incorrect_service": IAMQueryAuth_credentials_incorrect_service, + "IAMQueryAuth_credentials_incorrect_region": IAMQueryAuth_credentials_incorrect_region, + "IAMQueryAuth_credentials_invalid_date": IAMQueryAuth_credentials_invalid_date, + "IAMQueryAuth_non_existing_access_key": IAMQueryAuth_non_existing_access_key, + "IAMQueryAuth_invalid_date": IAMQueryAuth_invalid_date, + "IAMQueryAuth_date_mismatch": IAMQueryAuth_date_mismatch, + "IAMQueryAuth_unsigned_query_parameter": IAMQueryAuth_unsigned_query_parameter, + "IAMQueryAuth_incorrect_secret_key": IAMQueryAuth_incorrect_secret_key, + "IAMQueryAuth_invalid_sha256_payload_hash_ignored": IAMQueryAuth_invalid_sha256_payload_hash_ignored, + "IAMQueryAuth_with_expect_header": IAMQueryAuth_with_expect_header, + "IAMCreateUser_user_already_exists": IAMCreateUser_user_already_exists, + "IAMCreateUser_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, + "IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name, + "IAMCreateUser_long_user_name": IAMCreateUser_long_user_name, + "IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name, + "IAMCreateUser_invalid_tag_key": IAMCreateUser_invalid_tag_key, + "IAMCreateUser_invalid_tag_value": IAMCreateUser_invalid_tag_value, + "IAMCreateUser_long_tag_key": IAMCreateUser_long_tag_key, + "IAMCreateUser_long_tag_value": IAMCreateUser_long_tag_value, + "IAMCreateUser_duplicate_tag_keys": IAMCreateUser_duplicate_tag_keys, + "IAMCreateUser_success": IAMCreateUser_success, + "IAMCreateUser_default_path": IAMCreateUser_default_path, + "IAMCreateUser_invalid_path": IAMCreateUser_invalid_path, + "IAMCreateUser_long_path": IAMCreateUser_long_path, + "IAMGetUser_long_user_name": IAMGetUser_long_user_name, + "IAMGetUser_invalid_user_name": IAMGetUser_invalid_user_name, + "IAMGetUser_non_existing_user": IAMGetUser_non_existing_user, + "IAMGetUser_success": IAMGetUser_success, + "IAMGetUser_root_user": IAMGetUser_root_user, + "IAMListUsers_invalid_path_prefix": IAMListUsers_invalid_path_prefix, + "IAMListUsers_long_path_prefix": IAMListUsers_long_path_prefix, + "IAMListUsers_invalid_max_items": IAMListUsers_invalid_max_items, + "IAMListUsers_invalid_max_items_format": IAMListUsers_invalid_max_items_format, + "IAMListUsers_empty_result": IAMListUsers_empty_result, + "IAMListUsers_success": IAMListUsers_success, + "IAMListUsers_path_prefix": IAMListUsers_path_prefix, + "IAMListUsers_pagination": IAMListUsers_pagination, + "IAMListUsers_path_prefix_pagination": IAMListUsers_path_prefix_pagination, + "IAMDeleteUser_invalid_user_name": IAMDeleteUser_invalid_user_name, + "IAMDeleteUser_long_user_name": IAMDeleteUser_long_user_name, + "IAMDeleteUser_non_existing_user": IAMDeleteUser_non_existing_user, + "IAMDeleteUser_has_access_keys": IAMDeleteUser_has_access_keys, + "IAMDeleteUser_success": IAMDeleteUser_success, + "IAMUpdateUser_invalid_user_name": IAMUpdateUser_invalid_user_name, + "IAMUpdateUser_long_user_name": IAMUpdateUser_long_user_name, + "IAMUpdateUser_invalid_new_user_name": IAMUpdateUser_invalid_new_user_name, + "IAMUpdateUser_long_new_user_name": IAMUpdateUser_long_new_user_name, + "IAMUpdateUser_non_existing_user": IAMUpdateUser_non_existing_user, + "IAMUpdateUser_invalid_new_path": IAMUpdateUser_invalid_new_path, + "IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path, + "IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists, + "IAMUpdateUser_success": IAMUpdateUser_success, + "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, + "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, + "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, + "IAMCreateAccessKey_non_existing_user": IAMCreateAccessKey_non_existing_user, + "IAMCreateAccessKey_limit_exceeded": IAMCreateAccessKey_limit_exceeded, + "IAMCreateAccessKey_success": IAMCreateAccessKey_success, + "IAMUpdateAccessKey_missing_user_name": IAMUpdateAccessKey_missing_user_name, + "IAMUpdateAccessKey_invalid_user_name": IAMUpdateAccessKey_invalid_user_name, + "IAMUpdateAccessKey_long_user_name": IAMUpdateAccessKey_long_user_name, + "IAMUpdateAccessKey_missing_access_key_id": IAMUpdateAccessKey_missing_access_key_id, + "IAMUpdateAccessKey_access_key_id_too_short": IAMUpdateAccessKey_access_key_id_too_short, + "IAMUpdateAccessKey_access_key_id_too_long": IAMUpdateAccessKey_access_key_id_too_long, + "IAMUpdateAccessKey_invalid_access_key_id_chars": IAMUpdateAccessKey_invalid_access_key_id_chars, + "IAMUpdateAccessKey_missing_status": IAMUpdateAccessKey_missing_status, + "IAMUpdateAccessKey_invalid_status": IAMUpdateAccessKey_invalid_status, + "IAMUpdateAccessKey_non_existing_user": IAMUpdateAccessKey_non_existing_user, + "IAMUpdateAccessKey_non_existing_access_key": IAMUpdateAccessKey_non_existing_access_key, + "IAMUpdateAccessKey_success": IAMUpdateAccessKey_success, + "IAMDeleteAccessKey_missing_user_name": IAMDeleteAccessKey_missing_user_name, + "IAMDeleteAccessKey_invalid_user_name": IAMDeleteAccessKey_invalid_user_name, + "IAMDeleteAccessKey_long_user_name": IAMDeleteAccessKey_long_user_name, + "IAMDeleteAccessKey_missing_access_key_id": IAMDeleteAccessKey_missing_access_key_id, + "IAMDeleteAccessKey_access_key_id_too_short": IAMDeleteAccessKey_access_key_id_too_short, + "IAMDeleteAccessKey_access_key_id_too_long": IAMDeleteAccessKey_access_key_id_too_long, + "IAMDeleteAccessKey_invalid_access_key_id_chars": IAMDeleteAccessKey_invalid_access_key_id_chars, + "IAMDeleteAccessKey_non_existing_user": IAMDeleteAccessKey_non_existing_user, + "IAMDeleteAccessKey_non_existing_access_key": IAMDeleteAccessKey_non_existing_access_key, + "IAMDeleteAccessKey_success": IAMDeleteAccessKey_success, + "IAMGetAccessKeyLastUsed_missing_access_key_id": IAMGetAccessKeyLastUsed_missing_access_key_id, + "IAMGetAccessKeyLastUsed_access_key_id_too_short": IAMGetAccessKeyLastUsed_access_key_id_too_short, + "IAMGetAccessKeyLastUsed_access_key_id_too_long": IAMGetAccessKeyLastUsed_access_key_id_too_long, + "IAMGetAccessKeyLastUsed_invalid_access_key_id_chars": IAMGetAccessKeyLastUsed_invalid_access_key_id_chars, + "IAMGetAccessKeyLastUsed_non_existing_access_key": IAMGetAccessKeyLastUsed_non_existing_access_key, + "IAMGetAccessKeyLastUsed_success": IAMGetAccessKeyLastUsed_success, + "IAMListAccessKeys_missing_user_name": IAMListAccessKeys_missing_user_name, + "IAMListAccessKeys_invalid_user_name": IAMListAccessKeys_invalid_user_name, + "IAMListAccessKeys_long_user_name": IAMListAccessKeys_long_user_name, + "IAMListAccessKeys_invalid_max_items": IAMListAccessKeys_invalid_max_items, + "IAMListAccessKeys_invalid_max_items_format": IAMListAccessKeys_invalid_max_items_format, + "IAMListAccessKeys_non_existing_user": IAMListAccessKeys_non_existing_user, + "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, + "IAMListAccessKeys_success": IAMListAccessKeys_success, + "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, + "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, + "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, + "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, + "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, + "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, + "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, + "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, + "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, + "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, + "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, + "IAMPutUserPolicy_success": IAMPutUserPolicy_success, + "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, + "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, + "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, + "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, + "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, + "IAMGetUserPolicy_success": IAMGetUserPolicy_success, + "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, + "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, + "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, + "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, + "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, + "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, + "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, + "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, + "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, + "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, + "IAMListUserPolicies_success": IAMListUserPolicies_success, + "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, + "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, + "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, + "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, + "IAMCreateRole_already_exists": IAMCreateRole_already_exists, + "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, + "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, + "IAMCreateRole_long_path": IAMCreateRole_long_path, + "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, + "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, + "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, + "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, + "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, + "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, + "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, + "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, + "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, + "IAMCreateRole_success": IAMCreateRole_success, + "IAMCreateRole_defaults": IAMCreateRole_defaults, + "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, + "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, + "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, + "IAMGetRole_long_role_name": IAMGetRole_long_role_name, + "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, + "IAMGetRole_success": IAMGetRole_success, + "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, + "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, + "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, + "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, + "IAMListRoles_empty_result": IAMListRoles_empty_result, + "IAMListRoles_success": IAMListRoles_success, + "IAMListRoles_path_prefix": IAMListRoles_path_prefix, + "IAMListRoles_pagination": IAMListRoles_pagination, + "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, + "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, + "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, + "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, + "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, + "IAMDeleteRole_success": IAMDeleteRole_success, + "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, + "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, + "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, + "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, + "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, + "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, + "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, + "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, + "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, + "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, + "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, + "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, + "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, + "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, + "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, + "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, + "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, + "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, + "IAMPutRolePolicy_success": IAMPutRolePolicy_success, + "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, + "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, + "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, + "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, + "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, + "IAMGetRolePolicy_success": IAMGetRolePolicy_success, + "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, + "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, + "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, + "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, + "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, + "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, + "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, + "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, + "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, + "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, + "IAMListRolePolicies_success": IAMListRolePolicies_success, + "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, + "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, + "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, + "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, + "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, + "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, + "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, + "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, + "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, + "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, + "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, + "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, + "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, + "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, + "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, + "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, + "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, + "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, + "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, + "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, + "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, + "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, + "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, + "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, + "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, + "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, + "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, + "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, + "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, + "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, + "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, + "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, + "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, + "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, + "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, + "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, + "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, + "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, + "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, + "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, + "IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn, + "IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short, + "IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration, + "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action": IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action, + "IAMAssumeRoleWithWebIdentity_malformed_token": IAMAssumeRoleWithWebIdentity_malformed_token, + "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max": IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max, + "IAMAssumeRoleWithWebIdentity_nonexistent_role": IAMAssumeRoleWithWebIdentity_nonexistent_role, + "IAMAssumeRoleWithWebIdentity_no_matching_principal": IAMAssumeRoleWithWebIdentity_no_matching_principal, + "IAMAssumeRoleWithWebIdentity_no_issuer_match": IAMAssumeRoleWithWebIdentity_no_issuer_match, + "IAMAssumeRoleWithWebIdentity_condition_failed": IAMAssumeRoleWithWebIdentity_condition_failed, + "IAMAssumeRoleWithWebIdentity_explicit_deny": IAMAssumeRoleWithWebIdentity_explicit_deny, + "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list": IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list, + "IAMAssumeRoleWithWebIdentity_empty_client_id_list": IAMAssumeRoleWithWebIdentity_empty_client_id_list, + "IAMAssumeRoleWithWebIdentity_idp_communication_error": IAMAssumeRoleWithWebIdentity_idp_communication_error, + "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch": IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch, + "IAMAssumeRoleWithWebIdentity_policy_arns_rejected": IAMAssumeRoleWithWebIdentity_policy_arns_rejected, + "IAMAssumeRoleWithWebIdentity_provider_id_rejected": IAMAssumeRoleWithWebIdentity_provider_id_rejected, + "IAMAssumeRoleWithWebIdentity_session_policy_too_large": IAMAssumeRoleWithWebIdentity_session_policy_too_large, + "IAMAssumeRoleWithWebIdentity_session_policy_invalid": IAMAssumeRoleWithWebIdentity_session_policy_invalid, + "IAMAssumeRoleWithWebIdentity_oaud_condition_matches": IAMAssumeRoleWithWebIdentity_oaud_condition_matches, + "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, + "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, + "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, + "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, + "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, + "IAMGetCallerIdentity_no_auth": IAMGetCallerIdentity_no_auth, + "IAMGetCallerIdentity_wrong_version_is_invalid_action": IAMGetCallerIdentity_wrong_version_is_invalid_action, + "IAMGetCallerIdentity_incorrect_service_scope": IAMGetCallerIdentity_incorrect_service_scope, + "IAMAccessControl_ImplicitDenyNoMatchingPolicy": IAMAccessControl_ImplicitDenyNoMatchingPolicy, + "IAMAccessControl_AllowGrantsMatchingRequest": IAMAccessControl_AllowGrantsMatchingRequest, + "IAMAccessControl_NonMatchingStatementDoesNotGrant": IAMAccessControl_NonMatchingStatementDoesNotGrant, + "IAMAccessControl_ExplicitDenyOverridesAllow": IAMAccessControl_ExplicitDenyOverridesAllow, + "IAMAccessControl_MultipleStatementsEvaluatedIndependently": IAMAccessControl_MultipleStatementsEvaluatedIndependently, + "IAMAccessControl_MultipleInlinePoliciesCombinedAllow": IAMAccessControl_MultipleInlinePoliciesCombinedAllow, + "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins": IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins, + "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies": IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies, + "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow": IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow, + "IAMAccessControl_ActionMatchingVariants": IAMAccessControl_ActionMatchingVariants, + "IAMAccessControl_ActionAllowOneDenyAnotherByOmission": IAMAccessControl_ActionAllowOneDenyAnotherByOmission, + "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow": IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow, + "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded": IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded, + "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded": IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded, + "IAMAccessControl_ResourceMatchingVariants": IAMAccessControl_ResourceMatchingVariants, + "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction": IAMAccessControl_ResourceOneAllowedOneDeniedSameAction, + "IAMAccessControl_ResourceWildcardRequiredForListAction": IAMAccessControl_ResourceWildcardRequiredForListAction, + "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow": IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_NotResourceExcludesTarget": IAMAccessControl_NotResourceExcludesTarget, + "IAMAccessControl_NotResourceMultipleExcludedResources": IAMAccessControl_NotResourceMultipleExcludedResources, + "IAMAccessControl_NotResourceWildcardExclusion": IAMAccessControl_NotResourceWildcardExclusion, + "IAMAccessControl_ConditionStringOperators": IAMAccessControl_ConditionStringOperators, + "IAMAccessControl_ConditionStringMultipleExpectedValuesOR": IAMAccessControl_ConditionStringMultipleExpectedValuesOR, + "IAMAccessControl_ConditionArnOperators": IAMAccessControl_ConditionArnOperators, + "IAMAccessControl_ConditionIpAddressRealSourceIp": IAMAccessControl_ConditionIpAddressRealSourceIp, + "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow": IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow, + "IAMAccessControl_ConditionMultipleContextKeysANDed": IAMAccessControl_ConditionMultipleContextKeysANDed, + "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply": IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply, + "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins": IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins, + "IAMAccessControl_ConditionOneFailedConditionVoidsStatement": IAMAccessControl_ConditionOneFailedConditionVoidsStatement, + "IAMAccessControl_ConditionNullPrincipalTag": IAMAccessControl_ConditionNullPrincipalTag, + "IAMAccessControl_ConditionIfExistsPrincipalTag": IAMAccessControl_ConditionIfExistsPrincipalTag, + "IAMAccessControl_ConditionResourceTagOnTarget": IAMAccessControl_ConditionResourceTagOnTarget, + "IAMAccessControl_ConditionRequestTagOnCreateUser": IAMAccessControl_ConditionRequestTagOnCreateUser, + "IAMAccessControl_ConditionCurrentTimeBroadWindow": IAMAccessControl_ConditionCurrentTimeBroadWindow, + "IAMAccessControl_ConditionNumericOperators": IAMAccessControl_ConditionNumericOperators, + "IAMAccessControl_ConditionDateOperators": IAMAccessControl_ConditionDateOperators, + "IAMAccessControl_ConditionBoolOperator": IAMAccessControl_ConditionBoolOperator, + "IAMAccessControl_ConditionNullOperatorClaim": IAMAccessControl_ConditionNullOperatorClaim, + "IAMAccessControl_ConditionBinaryEqualsOperator": IAMAccessControl_ConditionBinaryEqualsOperator, + "IAMAccessControl_ConditionForAnyValueOperator": IAMAccessControl_ConditionForAnyValueOperator, + "IAMAccessControl_ConditionForAllValuesOperator": IAMAccessControl_ConditionForAllValuesOperator, + "IAMAccessControl_ConditionIfExistsTrustClaim": IAMAccessControl_ConditionIfExistsTrustClaim, + "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust": IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust, + "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed": IAMAccessControl_TrustPolicyFederatedExactMatchAllowed, + "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied": IAMAccessControl_TrustPolicyFederatedWrongProviderDenied, + "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny": IAMAccessControl_TrustPolicyFederatedArrayMatchesAny, + "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored": IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored, + "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed": IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed, + "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied": IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied, + "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed": IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed, + "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied": IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied, + "IAMAccessControl_TrustPolicyAudienceCorrectAllowed": IAMAccessControl_TrustPolicyAudienceCorrectAllowed, + "IAMAccessControl_TrustPolicyAudienceIncorrectDenied": IAMAccessControl_TrustPolicyAudienceIncorrectDenied, + "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed": IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed, + "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch": IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch, + "IAMAccessControl_TrustPolicyExplicitDenyStatement": IAMAccessControl_TrustPolicyExplicitDenyStatement, + "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants": IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants, + "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied": IAMAccessControl_TrustPolicyMissingRequiredClaimDenied, + "IAMAccessControl_UserInlinePolicyWorkflow": IAMAccessControl_UserInlinePolicyWorkflow, + "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath": IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath, + "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision": IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision, + "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy": IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy, + "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer": IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer, + "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck": IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck, + "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, + "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, + "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, + "PresignedAuth_missing_signature_query_param": PresignedAuth_missing_signature_query_param, + "PresignedAuth_missing_credentials_query_param": PresignedAuth_missing_credentials_query_param, + "PresignedAuth_malformed_creds_invalid_parts": PresignedAuth_malformed_creds_invalid_parts, + "PresignedAuth_creds_invalid_terminal": PresignedAuth_creds_invalid_terminal, + "PresignedAuth_creds_incorrect_service": PresignedAuth_creds_incorrect_service, + "PresignedAuth_creds_incorrect_region": PresignedAuth_creds_incorrect_region, + "PresignedAuth_creds_invalid_date": PresignedAuth_creds_invalid_date, + "PresignedAuth_missing_date_query": PresignedAuth_missing_date_query, + "PresignedAuth_dates_mismatch": PresignedAuth_dates_mismatch, + "PresignedAuth_non_existing_access_key_id": PresignedAuth_non_existing_access_key_id, + "PresignedAuth_missing_signed_headers_query_param": PresignedAuth_missing_signed_headers_query_param, + "PresignedAuth_unsigned_required_header": PresignedAuth_unsigned_required_header, + "PresignedAuth_unsigned_non_required_header": PresignedAuth_unsigned_non_required_header, + "PresignedAuth_missing_expiration_query_param": PresignedAuth_missing_expiration_query_param, + "PresignedAuth_invalid_expiration_query_param": PresignedAuth_invalid_expiration_query_param, + "PresignedAuth_negative_expiration_query_param": PresignedAuth_negative_expiration_query_param, + "PresignedAuth_exceeding_expiration_query_param": PresignedAuth_exceeding_expiration_query_param, + "PresignedAuth_expired_request": PresignedAuth_expired_request, + "PresignedAuth_incorrect_secret_key": PresignedAuth_incorrect_secret_key, + "PresignedAuth_sigv2_not_supported": PresignedAuth_sigv2_not_supported, + "PresignedAuth_PutObject_success": PresignedAuth_PutObject_success, + "PutObject_missing_object_lock_retention_config": PutObject_missing_object_lock_retention_config, + "PutObject_name_too_long": PutObject_name_too_long, + "PutObject_with_object_lock": PutObject_with_object_lock, + "PutObject_missing_bucket_lock": PutObject_missing_bucket_lock, + "PutObject_invalid_legal_hold": PutObject_invalid_legal_hold, + "PutObject_invalid_object_lock_mode": PutObject_invalid_object_lock_mode, + "PutObject_past_retain_until_date": PutObject_past_retain_until_date, + "PutObject_invalid_retain_until_date": PutObject_invalid_retain_until_date, + "PutObject_conditional_writes": PutObject_conditional_writes, + "PutObject_should_combine_metadata": PutObject_should_combine_metadata, + "PutObject_md5": PutObject_md5, + "PutObject_long_metadata": PutObject_long_metadata, + "PutObject_with_metadata": PutObject_with_metadata, + "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, + "PutObject_invalid_credentials": PutObject_invalid_credentials, + "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, + "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, + "PutObject_invalid_checksum_header": PutObject_invalid_checksum_header, + "PutObject_incorrect_checksums": PutObject_incorrect_checksums, + "PutObject_default_checksum": PutObject_default_checksum, + "PutObject_dir_object_default_checksum": PutObject_dir_object_default_checksum, + "PutObject_checksums_success": PutObject_checksums_success, + "PutObject_dir_object_checksums_success": PutObject_dir_object_checksums_success, + "PresignedAuth_Put_GetObject_with_data": PresignedAuth_Put_GetObject_with_data, + "PresignedAuth_Put_GetObject_with_UTF8_chars": PresignedAuth_Put_GetObject_with_UTF8_chars, + "PresignedAuth_UploadPart": PresignedAuth_UploadPart, + "CreateBucket_invalid_bucket_name": CreateBucket_invalid_bucket_name, + "CreateBucket_existing_bucket": CreateBucket_existing_bucket, + "CreateBucket_owned_by_you": CreateBucket_owned_by_you, + "CreateBucket_invalid_ownership": CreateBucket_invalid_ownership, + "CreateBucket_ownership_with_acl": CreateBucket_ownership_with_acl, + "CreateBucket_as_user": CreateBucket_as_user, + "CreateBucket_success": CreateBucket_success, + "CreateBucket_default_acl": CreateBucket_default_acl, + "CreateBucket_non_default_acl": CreateBucket_non_default_acl, + "CreateBucket_private_canned_acl": CreateBucket_private_canned_acl, + "CreateBucket_private_canned_acl_bucket_owner_enforced_ownership": CreateBucket_private_canned_acl_bucket_owner_enforced_ownership, + "CreateBucket_default_object_lock": CreateBucket_default_object_lock, + "CreateBucket_invalid_location_constraint": CreateBucket_invalid_location_constraint, + "CreateBucket_long_tags": CreateBucket_long_tags, + "CreateBucket_invalid_tags": CreateBucket_invalid_tags, + "CreateBucket_duplicate_keys": CreateBucket_duplicate_keys, + "CreateBucket_tag_count_limit": CreateBucket_tag_count_limit, + "CreateBucket_invalid_canned_acl": CreateBucket_invalid_canned_acl, + "HeadBucket_non_existing_bucket": HeadBucket_non_existing_bucket, + "HeadBucket_success": HeadBucket_success, + "ListBuckets_as_user": ListBuckets_as_user, + "ListBuckets_as_admin": ListBuckets_as_admin, + "ListBuckets_with_prefix": ListBuckets_with_prefix, + "ListBuckets_invalid_max_buckets": ListBuckets_invalid_max_buckets, + "ListBuckets_truncated": ListBuckets_truncated, + "ListBuckets_success": ListBuckets_success, + "ListBuckets_empty_success": ListBuckets_empty_success, + "DeleteBucket_non_existing_bucket": DeleteBucket_non_existing_bucket, + "DeleteBucket_non_empty_bucket": DeleteBucket_non_empty_bucket, + "DeleteBucket_incorrect_expected_bucket_owner": DeleteBucket_incorrect_expected_bucket_owner, + "DeleteBucket_success_status_code": DeleteBucket_success_status_code, + "PutBucketOwnershipControls_non_existing_bucket": PutBucketOwnershipControls_non_existing_bucket, + "PutBucketOwnershipControls_multiple_rules": PutBucketOwnershipControls_multiple_rules, + "PutBucketOwnershipControls_invalid_ownership": PutBucketOwnershipControls_invalid_ownership, + "PutBucketOwnershipControls_empty_rules": PutBucketOwnershipControls_empty_rules, + "PutBucketOwnershipControls_success": PutBucketOwnershipControls_success, + "GetBucketOwnershipControls_non_existing_bucket": GetBucketOwnershipControls_non_existing_bucket, + "GetBucketOwnershipControls_default_ownership": GetBucketOwnershipControls_default_ownership, + "GetBucketOwnershipControls_success": GetBucketOwnershipControls_success, + "DeleteBucketOwnershipControls_non_existing_bucket": DeleteBucketOwnershipControls_non_existing_bucket, + "DeleteBucketOwnershipControls_success": DeleteBucketOwnershipControls_success, + "PutBucketTagging_non_existing_bucket": PutBucketTagging_non_existing_bucket, + "PutBucketTagging_long_tags": PutBucketTagging_long_tags, + "PutBucketTagging_invalid_tags": PutBucketTagging_invalid_tags, + "PutBucketTagging_duplicate_keys": PutBucketTagging_duplicate_keys, + "PutBucketTagging_tag_count_limit": PutBucketTagging_tag_count_limit, + "PutBucketTagging_success": PutBucketTagging_success, + "PutBucketTagging_success_status": PutBucketTagging_success_status, + "GetBucketTagging_non_existing_bucket": GetBucketTagging_non_existing_bucket, + "GetBucketTagging_unset_tags": GetBucketTagging_unset_tags, + "GetBucketTagging_success": GetBucketTagging_success, + "DeleteBucketTagging_non_existing_object": DeleteBucketTagging_non_existing_object, + "DeleteBucketTagging_success_status": DeleteBucketTagging_success_status, + "DeleteBucketTagging_success": DeleteBucketTagging_success, + "GetBucketLocation_success": GetBucketLocation_success, + "GetBucketLocation_non_exist": GetBucketLocation_non_exist, + "GetBucketLocation_no_access": GetBucketLocation_no_access, + "PutObject_non_existing_bucket": PutObject_non_existing_bucket, + "PutObject_special_chars": PutObject_special_chars, + "PutObject_tagging": PutObject_tagging, + "PutObject_success": PutObject_success, + "PutObject_default_content_type": PutObject_default_content_type, + "PutObject_invalid_object_names": PutObject_invalid_object_names, + "PutObject_object_acl_not_supported": PutObject_object_acl_not_supported, + "PutObject_false_negative_object_names": PutObject_false_negative_object_names, + "PutObject_racey_success": PutObject_racey_success, + "HeadObject_non_existing_object": HeadObject_non_existing_object, + "HeadObject_invalid_part_number": HeadObject_invalid_part_number, + "HeadObject_directory_object_noslash": HeadObject_directory_object_noslash, + "HeadObject_non_existing_dir_object": HeadObject_non_existing_dir_object, + "HeadObject_incidental_dir_object": HeadObject_incidental_dir_object, + "HeadObject_name_too_long": HeadObject_name_too_long, + "HeadObject_invalid_parent_dir": HeadObject_invalid_parent_dir, + "HeadObject_with_range": HeadObject_with_range, + "HeadObject_by_range_resp_status": HeadObject_by_range_resp_status, + "HeadObject_zero_len_with_range": HeadObject_zero_len_with_range, + "HeadObject_dir_with_range": HeadObject_dir_with_range, + "HeadObject_conditional_reads": HeadObject_conditional_reads, + "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, + "HeadObject_checksums": HeadObject_checksums, + "HeadObject_ranged_with_checksum_mode": HeadObject_ranged_with_checksum_mode, + "HeadObject_success": HeadObject_success, + "HeadObject_overrides_success": HeadObject_overrides_success, + "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, + "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, + "HeadObject_range_and_part_number": HeadObject_range_and_part_number, + "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, + "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, + "HeadObject_mp_part_number_resp_status": HeadObject_mp_part_number_resp_status, + "HeadObject_non_mp_part_number_1_success": HeadObject_non_mp_part_number_1_success, + "HeadObject_empty_object_part_number_1": HeadObject_empty_object_part_number_1, + "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, + "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, + "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs, + "GetObjectAttributes_invalid_parent": GetObjectAttributes_invalid_parent, + "GetObjectAttributes_invalid_single_attribute": GetObjectAttributes_invalid_single_attribute, + "GetObjectAttributes_empty_attrs": GetObjectAttributes_empty_attrs, + "GetObjectAttributes_existing_object": GetObjectAttributes_existing_object, + "GetObjectAttributes_checksums": GetObjectAttributes_checksums, + "GetObject_non_existing_key": GetObject_non_existing_key, + "GetObject_directory_object_noslash": GetObject_directory_object_noslash, + "GetObject_with_range": GetObject_with_range, + "GetObject_zero_len_with_range": GetObject_zero_len_with_range, + "GetObject_dir_with_range": GetObject_dir_with_range, + "GetObject_invalid_parent": GetObject_invalid_parent, + "GetObject_large_object": GetObject_large_object, + "GetObject_conditional_reads": GetObject_conditional_reads, + "GetObject_not_enabled_checksum_mode": GetObject_not_enabled_checksum_mode, + "GetObject_checksums": GetObject_checksums, + "GetObject_dir_object_checksum": GetObject_dir_object_checksum, + "GetObject_ranged_with_checksum_mode": GetObject_ranged_with_checksum_mode, + "GetObject_success": GetObject_success, + "GetObject_directory_success": GetObject_directory_success, + "GetObject_by_range_resp_status": GetObject_by_range_resp_status, + "GetObject_non_existing_dir_object": GetObject_non_existing_dir_object, + "GetObject_incidental_dir_object": GetObject_incidental_dir_object, + "GetObject_overrides_success": GetObject_overrides_success, + "GetObject_overrides_presign_success": GetObject_overrides_presign_success, + "GetObject_overrides_fail_public": GetObject_overrides_fail_public, + "GetObject_invalid_part_number": GetObject_invalid_part_number, + "GetObject_range_and_part_number": GetObject_range_and_part_number, + "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, + "GetObject_mp_part_number_success": GetObject_mp_part_number_success, + "GetObject_mp_part_number_resp_status": GetObject_mp_part_number_resp_status, + "GetObject_non_mp_part_number_1_success": GetObject_non_mp_part_number_1_success, + "GetObject_empty_object_part_number_1": GetObject_empty_object_part_number_1, + "ListObjects_non_existing_bucket": ListObjects_non_existing_bucket, + "ListObjects_with_prefix": ListObjects_with_prefix, + "ListObjects_truncated": ListObjects_truncated, + "ListObjects_paginated": ListObjects_paginated, + "ListObjects_invalid_max_keys": ListObjects_invalid_max_keys, + "ListObjects_max_keys_0": ListObjects_max_keys_0, + "ListObjects_delimiter": ListObjects_delimiter, + "ListObjects_max_keys_none": ListObjects_max_keys_none, + "ListObjects_marker_not_from_obj_list": ListObjects_marker_not_from_obj_list, + "ListObjects_list_all_objs": ListObjects_list_all_objs, + "ListObjects_nested_dir_file_objs": ListObjects_nested_dir_file_objs, + "ListObjects_check_owner": ListObjects_check_owner, + "ListObjects_non_truncated_common_prefixes": ListObjects_non_truncated_common_prefixes, + "ListObjects_should_not_list_pending_mps": ListObjects_should_not_list_pending_mps, + "ListObjects_mp_masking_with_marker": ListObjects_mp_masking_with_marker, + "ListObjects_mp_masking_truncation": ListObjects_mp_masking_truncation, + "ListObjects_mp_masking_delimiter": ListObjects_mp_masking_delimiter, + "ListObjectsV2_non_truncated_common_prefixes": ListObjectsV2_non_truncated_common_prefixes, + "ListObjectsV2_invalid_parent_prefix": ListObjectsV2_invalid_parent_prefix, + "ListObjectsV2_should_not_list_pending_mps": ListObjectsV2_should_not_list_pending_mps, + "ListObjectsV2_mp_masking_start_after": ListObjectsV2_mp_masking_start_after, + "ListObjectsV2_mp_masking_truncation": ListObjectsV2_mp_masking_truncation, + "ListObjectsV2_mp_masking_delimiter": ListObjectsV2_mp_masking_delimiter, + "ListObjects_with_checksum": ListObjects_with_checksum, + "ListObjectsV2_start_after": ListObjectsV2_start_after, + "ListObjectsV2_both_start_after_and_continuation_token": ListObjectsV2_both_start_after_and_continuation_token, + "ListObjectsV2_start_after_not_in_list": ListObjectsV2_start_after_not_in_list, + "ListObjectsV2_start_after_empty_result": ListObjectsV2_start_after_empty_result, + "ListObjectsV2_both_delimiter_and_prefix": ListObjectsV2_both_delimiter_and_prefix, + "ListObjectsV2_single_dir_object_with_delim_and_prefix": ListObjectsV2_single_dir_object_with_delim_and_prefix, + "ListObjectsV2_truncated_common_prefixes": ListObjectsV2_truncated_common_prefixes, + "ListObjectsV2_all_objs_max_keys": ListObjectsV2_all_objs_max_keys, + "ListObjectsV2_list_all_objs": ListObjectsV2_list_all_objs, + "ListObjectsV2_with_owner": ListObjectsV2_with_owner, + "ListObjectsV2_with_checksum": ListObjectsV2_with_checksum, + "ListObjectVersions_VD_success": ListObjectVersions_VD_success, + "DeleteObject_non_existing_object": DeleteObject_non_existing_object, + "DeleteObject_directory_object_noslash": DeleteObject_directory_object_noslash, + "DeleteObject_non_empty_dir_obj": DeleteObject_non_empty_dir_obj, + "DeleteObject_conditional_writes": DeleteObject_conditional_writes, + "DeleteObject_name_too_long": DeleteObject_name_too_long, + "CopyObject_overwrite_same_dir_object": CopyObject_overwrite_same_dir_object, + "CopyObject_overwrite_same_file_object": CopyObject_overwrite_same_file_object, + "DeleteObject_non_existing_dir_object": DeleteObject_non_existing_dir_object, + "DeleteObject_directory_object": DeleteObject_directory_object, + "DeleteObject_success": DeleteObject_success, + "DeleteObject_success_status_code": DeleteObject_success_status_code, + "DeleteObject_incorrect_expected_bucket_owner": DeleteObject_incorrect_expected_bucket_owner, + "DeleteObject_expected_bucket_owner": DeleteObject_expected_bucket_owner, + "DeleteObjects_empty_input": DeleteObjects_empty_input, + "DeleteObjects_non_existing_objects": DeleteObjects_non_existing_objects, + "DeleteObjects_success": DeleteObjects_success, + "CopyObject_non_existing_dst_bucket": CopyObject_non_existing_dst_bucket, + "CopyObject_not_owned_source_bucket": CopyObject_not_owned_source_bucket, + "CopyObject_copy_to_itself": CopyObject_copy_to_itself, + "CopyObject_copy_to_itself_invalid_directive": CopyObject_copy_to_itself_invalid_directive, + "CopyObject_should_replace_tagging": CopyObject_should_replace_tagging, + "CopyObject_should_copy_tagging": CopyObject_should_copy_tagging, + "CopyObject_invalid_tagging_directive": CopyObject_invalid_tagging_directive, + "CopyObject_long_metadata": CopyObject_long_metadata, + "CopyObject_to_itself_with_new_metadata": CopyObject_to_itself_with_new_metadata, + "CopyObject_copy_source_starting_with_slash": CopyObject_copy_source_starting_with_slash, + "CopyObject_invalid_copy_source": CopyObject_invalid_copy_source, + "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, + "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, + "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, + "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, + "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, + "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, + "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, + "CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode, + "CopyObject_with_legal_hold": CopyObject_with_legal_hold, + "CopyObject_with_retention_lock": CopyObject_with_retention_lock, + "CopyObject_conditional_reads": CopyObject_conditional_reads, + "CopyObject_object_acl_not_supported": CopyObject_object_acl_not_supported, + "CopyObject_with_metadata": CopyObject_with_metadata, + "CopyObject_invalid_checksum_algorithm": CopyObject_invalid_checksum_algorithm, + "CopyObject_create_checksum_on_copy": CopyObject_create_checksum_on_copy, + "CopyObject_should_copy_the_existing_checksum": CopyObject_should_copy_the_existing_checksum, + "CopyObject_should_replace_the_existing_checksum": CopyObject_should_replace_the_existing_checksum, + "CopyObject_to_itself_by_replacing_the_checksum": CopyObject_to_itself_by_replacing_the_checksum, + "CopyObject_with_special_characters": CopyObject_with_special_characters, + "CopyObject_success": CopyObject_success, + "CopyObject_incorrect_source_bucket_expected_owner": CopyObject_incorrect_source_bucket_expected_owner, + "PutObjectTagging_non_existing_object": PutObjectTagging_non_existing_object, + "PutObjectTagging_long_tags": PutObjectTagging_long_tags, + "PutObjectTagging_duplicate_keys": PutObjectTagging_duplicate_keys, + "PutObjectTagging_tag_count_limit": PutObjectTagging_tag_count_limit, + "PutObjectTagging_invalid_tags": PutObjectTagging_invalid_tags, + "PutObjectTagging_success": PutObjectTagging_success, + "GetObjectTagging_non_existing_object": GetObjectTagging_non_existing_object, + "GetObjectTagging_unset_tags": GetObjectTagging_unset_tags, + "GetObjectTagging_invalid_parent": GetObjectTagging_invalid_parent, + "GetObjectTagging_success": GetObjectTagging_success, + "DeleteObjectTagging_non_existing_object": DeleteObjectTagging_non_existing_object, + "DeleteObjectTagging_success_status": DeleteObjectTagging_success_status, + "DeleteObjectTagging_success": DeleteObjectTagging_success, + "DeleteObjectTagging_expected_bucket_owner": DeleteObjectTagging_expected_bucket_owner, + "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, + "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, + "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, + "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, + "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, + "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, + "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, + "CreateMultipartUpload_with_object_lock_invalid_retention": CreateMultipartUpload_with_object_lock_invalid_retention, + "CreateMultipartUpload_past_retain_until_date": CreateMultipartUpload_past_retain_until_date, + "CreateMultipartUpload_invalid_legal_hold": CreateMultipartUpload_invalid_legal_hold, + "CreateMultipartUpload_invalid_object_lock_mode": CreateMultipartUpload_invalid_object_lock_mode, + "CreateMultipartUpload_object_acl_not_supported": CreateMultipartUpload_object_acl_not_supported, + "CreateMultipartUpload_invalid_checksum_algorithm": CreateMultipartUpload_invalid_checksum_algorithm, + "CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type": CreateMultipartUpload_empty_checksum_algorithm_with_checksum_type, + "CreateMultipartUpload_type_algo_mismatch": CreateMultipartUpload_type_algo_mismatch, + "CreateMultipartUpload_invalid_checksum_type": CreateMultipartUpload_invalid_checksum_type, + "CreateMultipartUpload_valid_algo_type": CreateMultipartUpload_valid_algo_type, + "CreateMultipartUpload_success": CreateMultipartUpload_success, + "UploadPart_non_existing_bucket": UploadPart_non_existing_bucket, + "UploadPart_invalid_part_number": UploadPart_invalid_part_number, + "UploadPart_non_existing_key": UploadPart_non_existing_key, + "UploadPart_non_existing_mp_upload": UploadPart_non_existing_mp_upload, + "UploadPart_multiple_checksum_headers": UploadPart_multiple_checksum_headers, + "UploadPart_invalid_checksum_header": UploadPart_invalid_checksum_header, + "UploadPart_checksum_header_and_algo_mismatch": UploadPart_checksum_header_and_algo_mismatch, + "UploadPart_checksum_algorithm_mistmatch_on_initialization": UploadPart_checksum_algorithm_mistmatch_on_initialization, + "UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value": UploadPart_checksum_algorithm_mistmatch_on_initialization_with_value, + "UploadPart_incorrect_checksums": UploadPart_incorrect_checksums, + "UploadPart_no_checksum_with_full_object_checksum_type": UploadPart_no_checksum_with_full_object_checksum_type, + "UploadPart_no_checksum_with_composite_checksum_type": UploadPart_no_checksum_with_composite_checksum_type, + "UploadPart_with_checksums_success": UploadPart_with_checksums_success, + "UploadPart_success": UploadPart_success, + "UploadPartCopy_non_existing_bucket": UploadPartCopy_non_existing_bucket, + "UploadPartCopy_incorrect_uploadId": UploadPartCopy_incorrect_uploadId, + "UploadPartCopy_incorrect_object_key": UploadPartCopy_incorrect_object_key, + "UploadPartCopy_invalid_part_number": UploadPartCopy_invalid_part_number, + "UploadPartCopy_invalid_copy_source": UploadPartCopy_invalid_copy_source, + "UploadPartCopy_non_existing_source_bucket": UploadPartCopy_non_existing_source_bucket, + "UploadPartCopy_non_existing_source_object_key": UploadPartCopy_non_existing_source_object_key, + "UploadPartCopy_success": UploadPartCopy_success, + "UploadPartCopy_by_range_invalid_ranges": UploadPartCopy_by_range_invalid_ranges, + "UploadPartCopy_exceeding_copy_source_range": UploadPartCopy_exceeding_copy_source_range, + "UploadPartCopy_greater_range_than_obj_size": UploadPartCopy_greater_range_than_obj_size, + "UploadPartCopy_by_range_success": UploadPartCopy_by_range_success, + "UploadPartCopy_conditional_reads": UploadPartCopy_conditional_reads, + "UploadPartCopy_incorrect_source_bucket_expected_owner": UploadPartCopy_incorrect_source_bucket_expected_owner, + "UploadPartCopy_should_copy_the_checksum": UploadPartCopy_should_copy_the_checksum, + "UploadPartCopy_should_not_copy_the_checksum": UploadPartCopy_should_not_copy_the_checksum, + "UploadPartCopy_should_calculate_the_checksum": UploadPartCopy_should_calculate_the_checksum, + "ListParts_incorrect_uploadId": ListParts_incorrect_uploadId, + "ListParts_incorrect_object_key": ListParts_incorrect_object_key, + "ListParts_invalid_max_parts": ListParts_invalid_max_parts, + "ListParts_invalid_part_number_marker": ListParts_invalid_part_number_marker, + "ListParts_default_max_parts": ListParts_default_max_parts, + "ListParts_truncated": ListParts_truncated, + "ListParts_with_checksums": ListParts_with_checksums, + "ListParts_null_checksums": ListParts_null_checksums, + "ListParts_success": ListParts_success, + "ListMultipartUploads_non_existing_bucket": ListMultipartUploads_non_existing_bucket, + "ListMultipartUploads_empty_result": ListMultipartUploads_empty_result, + "ListMultipartUploads_invalid_max_uploads": ListMultipartUploads_invalid_max_uploads, + "ListMultipartUploads_max_uploads": ListMultipartUploads_max_uploads, + "ListMultipartUploads_exceeding_max_uploads": ListMultipartUploads_exceeding_max_uploads, + "ListMultipartUploads_ignore_upload_id_marker": ListMultipartUploads_ignore_upload_id_marker, + "ListMultipartUploads_invalid_uploadId_marker": ListMultipartUploads_invalid_uploadId_marker, + "ListMultipartUploads_keyMarker_not_from_list": ListMultipartUploads_keyMarker_not_from_list, + "ListMultipartUploads_delimiter_truncated": ListMultipartUploads_delimiter_truncated, + "ListMultipartUploads_prefix": ListMultipartUploads_prefix, + "ListMultipartUploads_both_delimiter_and_prefix": ListMultipartUploads_both_delimiter_and_prefix, + "ListMultipartUploads_with_checksums": ListMultipartUploads_with_checksums, + "AbortMultipartUpload_non_existing_bucket": AbortMultipartUpload_non_existing_bucket, + "AbortMultipartUpload_incorrect_uploadId": AbortMultipartUpload_incorrect_uploadId, + "AbortMultipartUpload_incorrect_object_key": AbortMultipartUpload_incorrect_object_key, + "AbortMultipartUpload_success": AbortMultipartUpload_success, + "AbortMultipartUpload_success_status_code": AbortMultipartUpload_success_status_code, + "AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time, + "CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket, + "CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number, + "CompleteMultipartUpload_default_content_type": CompleteMultipartUpload_default_content_type, + "CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag, + "CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size, + "CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts, + "CompleteMultipartUpload_missing_part_fields": CompleteMultipartUpload_missing_part_fields, + "CompleteMultipartUpload_incorrect_part_number": CompleteMultipartUpload_incorrect_part_number, + "CompleteMultipartUpload_incorrect_parts_order": CompleteMultipartUpload_incorrect_parts_order, + "CompleteMultipartUpload_mpu_object_size": CompleteMultipartUpload_mpu_object_size, + "CompleteMultipartUpload_conditional_writes": CompleteMultipartUpload_conditional_writes, + "CompleteMultipartUpload_with_metadata": CompleteMultipartUpload_with_metadata, + "CompleteMultipartUpload_invalid_checksum_type": CompleteMultipartUpload_invalid_checksum_type, + "CompleteMultipartUpload_invalid_checksum_part": CompleteMultipartUpload_invalid_checksum_part, + "CompleteMultipartUpload_multiple_checksum_part": CompleteMultipartUpload_multiple_checksum_part, + "CompleteMultipartUpload_incorrect_checksum_part": CompleteMultipartUpload_incorrect_checksum_part, + "CompleteMultipartUpload_different_checksum_part": CompleteMultipartUpload_different_checksum_part, + "CompleteMultipartUpload_missing_part_checksum": CompleteMultipartUpload_missing_part_checksum, + "CompleteMultipartUpload_multiple_final_checksums": CompleteMultipartUpload_multiple_final_checksums, + "CompleteMultipartUpload_invalid_final_checksums": CompleteMultipartUpload_invalid_final_checksums, + "CompleteMultipartUpload_incorrect_final_checksums": CompleteMultipartUpload_incorrect_final_checksums, + "CompleteMultipartUpload_should_calculate_the_final_checksum_full_object": CompleteMultipartUpload_should_calculate_the_final_checksum_full_object, + "CompleteMultipartUpload_should_verify_the_final_checksum": CompleteMultipartUpload_should_verify_the_final_checksum, + "CompleteMultipartUpload_should_verify_final_composite_checksum": CompleteMultipartUpload_should_verify_final_composite_checksum, + "CompleteMultipartUpload_invalid_final_composite_checksum": CompleteMultipartUpload_invalid_final_composite_checksum, + "CompleteMultipartUpload_checksum_type_mismatch": CompleteMultipartUpload_checksum_type_mismatch, + "CompleteMultipartUpload_should_ignore_the_final_checksum": CompleteMultipartUpload_should_ignore_the_final_checksum, + "CompleteMultipartUpload_should_succeed_without_final_checksum_type": CompleteMultipartUpload_should_succeed_without_final_checksum_type, + "CompleteMultipartUpload_success": CompleteMultipartUpload_success, + "CompleteMultipartUpload_already_completed": CompleteMultipartUpload_already_completed, + "CompleteMultipartUpload_racey_success": CompleteMultipartUpload_racey_success, + "CompleteMultipartUpload_racey_data_integrity": CompleteMultipartUpload_racey_data_integrity, + "PutBucketAcl_non_existing_bucket": PutBucketAcl_non_existing_bucket, + "PutBucketAcl_disabled": PutBucketAcl_disabled, + "PutBucketAcl_none_of_the_options_specified": PutBucketAcl_none_of_the_options_specified, + "PutBucketAcl_invalid_canned_acl": PutBucketAcl_invalid_canned_acl, + "PutBucketAcl_invalid_acl_canned_and_acp": PutBucketAcl_invalid_acl_canned_and_acp, + "PutBucketAcl_invalid_acl_canned_and_grants": PutBucketAcl_invalid_acl_canned_and_grants, + "PutBucketAcl_invalid_acl_acp_and_grants": PutBucketAcl_invalid_acl_acp_and_grants, + "PutBucketAcl_invalid_owner": PutBucketAcl_invalid_owner, + "PutBucketAcl_invalid_owner_not_in_body": PutBucketAcl_invalid_owner_not_in_body, + "PutBucketAcl_invalid_empty_owner_id_in_body": PutBucketAcl_invalid_empty_owner_id_in_body, + "PutBucketAcl_invalid_permission_in_body": PutBucketAcl_invalid_permission_in_body, + "PutBucketAcl_invalid_grantee_type_in_body": PutBucketAcl_invalid_grantee_type_in_body, + "PutBucketAcl_empty_grantee_ID_in_body": PutBucketAcl_empty_grantee_ID_in_body, + "PutBucketAcl_success_access_denied": PutBucketAcl_success_access_denied, + "PutBucketAcl_success_grants": PutBucketAcl_success_grants, + "PutBucketAcl_success_canned_acl": PutBucketAcl_success_canned_acl, + "PutBucketAcl_success_acp": PutBucketAcl_success_acp, + "GetBucketAcl_non_existing_bucket": GetBucketAcl_non_existing_bucket, + "GetBucketAcl_translation_canned_public_read": GetBucketAcl_translation_canned_public_read, + "GetBucketAcl_translation_canned_public_read_write": GetBucketAcl_translation_canned_public_read_write, + "GetBucketAcl_translation_canned_private": GetBucketAcl_translation_canned_private, + "GetBucketAcl_access_denied": GetBucketAcl_access_denied, + "GetBucketAcl_success": GetBucketAcl_success, + "PutBucketPolicy_non_existing_bucket": PutBucketPolicy_non_existing_bucket, + "PutBucketPolicy_invalid_json": PutBucketPolicy_invalid_json, + "PutBucketPolicy_statement_not_provided": PutBucketPolicy_statement_not_provided, + "PutBucketPolicy_empty_statement": PutBucketPolicy_empty_statement, + "PutBucketPolicy_invalid_effect": PutBucketPolicy_invalid_effect, + "PutBucketPolicy_invalid_action": PutBucketPolicy_invalid_action, + "PutBucketPolicy_empty_principals_string": PutBucketPolicy_empty_principals_string, + "PutBucketPolicy_empty_principals_array": PutBucketPolicy_empty_principals_array, + "PutBucketPolicy_principals_aws_struct_empty_string": PutBucketPolicy_principals_aws_struct_empty_string, + "PutBucketPolicy_principals_aws_struct_empty_string_slice": PutBucketPolicy_principals_aws_struct_empty_string_slice, + "PutBucketPolicy_principals_incorrect_wildcard_usage": PutBucketPolicy_principals_incorrect_wildcard_usage, + "PutBucketPolicy_non_existing_principals": PutBucketPolicy_non_existing_principals, + "PutBucketPolicy_empty_resources_string": PutBucketPolicy_empty_resources_string, + "PutBucketPolicy_empty_resources_array": PutBucketPolicy_empty_resources_array, + "PutBucketPolicy_invalid_resource_prefix": PutBucketPolicy_invalid_resource_prefix, + "PutBucketPolicy_invalid_resource_with_starting_slash": PutBucketPolicy_invalid_resource_with_starting_slash, + "PutBucketPolicy_duplicate_resource": PutBucketPolicy_duplicate_resource, + "PutBucketPolicy_incorrect_bucket_name": PutBucketPolicy_incorrect_bucket_name, + "PutBucketPolicy_action_resource_mismatch": PutBucketPolicy_action_resource_mismatch, + "PutBucketPolicy_explicit_deny": PutBucketPolicy_explicit_deny, + "PutBucketPolicy_multi_wildcard_resource": PutBucketPolicy_multi_wildcard_resource, + "PutBucketPolicy_any_char_match": PutBucketPolicy_any_char_match, + "PutBucketPolicy_version": PutBucketPolicy_version, + "PutBucketPolicy_success": PutBucketPolicy_success, + "PutBucketPolicy_status": PutBucketPolicy_status, + "GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket, + "GetBucketPolicy_not_set": GetBucketPolicy_not_set, + "GetBucketPolicy_success": GetBucketPolicy_success, + "GetBucketPolicyStatus_non_existing_bucket": GetBucketPolicyStatus_non_existing_bucket, + "GetBucketPolicyStatus_no_such_bucket_policy": GetBucketPolicyStatus_no_such_bucket_policy, + "GetBucketPolicyStatus_success": GetBucketPolicyStatus_success, + "DeleteBucketPolicy_non_existing_bucket": DeleteBucketPolicy_non_existing_bucket, + "DeleteBucketPolicy_remove_before_setting": DeleteBucketPolicy_remove_before_setting, + "DeleteBucketPolicy_success": DeleteBucketPolicy_success, + "PutBucketCors_non_existing_bucket": PutBucketCors_non_existing_bucket, + "PutBucketCors_empty_cors_rules": PutBucketCors_empty_cors_rules, + "PutBucketCors_invalid_allowed_origins": PutBucketCors_invalid_allowed_origins, + "PutBucketCors_invalid_method": PutBucketCors_invalid_method, + "PutBucketCors_invalid_header": PutBucketCors_invalid_header, + "PutBucketCors_md5": PutBucketCors_md5, + "GetBucketCors_non_existing_bucket": GetBucketCors_non_existing_bucket, + "GetBucketCors_no_such_bucket_cors": GetBucketCors_no_such_bucket_cors, + "GetBucketCors_success": GetBucketCors_success, + "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, + "DeleteBucketCors_success": DeleteBucketCors_success, + "PutBucketCors_success": PutBucketCors_success, + "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, + "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, + "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, + "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, + "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, + "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, + "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, + "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, + "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, + "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, + "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, + "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code, + "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code, + "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large, + "PutBucketWebsite_success": PutBucketWebsite_success, + "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, + "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, + "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, + "GetBucketWebsite_success": GetBucketWebsite_success, + "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, + "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, + "DeleteBucketWebsite_success": DeleteBucketWebsite_success, + "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, + "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, + "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, + "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, + "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, + "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, + "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, + "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, + "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, + "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, + "WebsiteHosting_object_redirect_location": WebsiteHosting_object_redirect_location, + "WebsiteHosting_index_document": WebsiteHosting_index_document, + "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, + "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, + "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers, + "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted, + "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden, + "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin, + "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, + "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, + "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, + "PreflightOPTIONS_invalid_request_headers": PreflightOPTIONS_invalid_request_headers, + "PreflightOPTIONS_unset_bucket_cors": PreflightOPTIONS_unset_bucket_cors, + "PreflightOPTIONS_access_forbidden": PreflightOPTIONS_access_forbidden, + "PreflightOPTIONS_access_granted": PreflightOPTIONS_access_granted, + "CORSMiddleware_invalid_method": CORSMiddleware_invalid_method, + "CORSMiddleware_invalid_headers": CORSMiddleware_invalid_headers, + "CORSMiddleware_access_forbidden": CORSMiddleware_access_forbidden, + "CORSMiddleware_access_granted": CORSMiddleware_access_granted, + "PutObjectLockConfiguration_non_existing_bucket": PutObjectLockConfiguration_non_existing_bucket, + "PutObjectLockConfiguration_empty_request_body": PutObjectLockConfiguration_empty_request_body, + "PutObjectLockConfiguration_malformed_body": PutObjectLockConfiguration_malformed_body, + "PutObjectLockConfiguration_not_enabled_on_bucket_creation": PutObjectLockConfiguration_not_enabled_on_bucket_creation, + "PutObjectLockConfiguration_invalid_status": PutObjectLockConfiguration_invalid_status, + "PutObjectLockConfiguration_invalid_mode": PutObjectLockConfiguration_invalid_mode, + "PutObjectLockConfiguration_both_years_and_days": PutObjectLockConfiguration_both_years_and_days, + "PutObjectLockConfiguration_invalid_years_days": PutObjectLockConfiguration_invalid_years_days, + "PutObjectLockConfiguration_success": PutObjectLockConfiguration_success, + "GetObjectLockConfiguration_non_existing_bucket": GetObjectLockConfiguration_non_existing_bucket, + "GetObjectLockConfiguration_unset_config": GetObjectLockConfiguration_unset_config, + "GetObjectLockConfiguration_success": GetObjectLockConfiguration_success, + "PutObjectRetention_non_existing_bucket": PutObjectRetention_non_existing_bucket, + "PutObjectRetention_non_existing_object": PutObjectRetention_non_existing_object, + "PutObjectRetention_unset_bucket_object_lock_config": PutObjectRetention_unset_bucket_object_lock_config, + "PutObjectRetention_expired_retain_until_date": PutObjectRetention_expired_retain_until_date, + "PutObjectRetention_invalid_mode": PutObjectRetention_invalid_mode, + "PutObjectRetention_overwrite_compliance_mode": PutObjectRetention_overwrite_compliance_mode, + "PutObjectRetention_overwrite_compliance_with_compliance": PutObjectRetention_overwrite_compliance_with_compliance, + "PutObjectRetention_overwrite_governance_with_governance": PutObjectRetention_overwrite_governance_with_governance, + "PutObjectRetention_overwrite_governance_without_bypass_specified": PutObjectRetention_overwrite_governance_without_bypass_specified, + "PutObjectRetention_overwrite_governance_with_permission": PutObjectRetention_overwrite_governance_with_permission, + "PutObjectRetention_success": PutObjectRetention_success, + "GetObjectRetention_non_existing_bucket": GetObjectRetention_non_existing_bucket, + "GetObjectRetention_non_existing_object": GetObjectRetention_non_existing_object, + "GetObjectRetention_disabled_lock": GetObjectRetention_disabled_lock, + "GetObjectRetention_unset_config": GetObjectRetention_unset_config, + "GetObjectRetention_success": GetObjectRetention_success, + "PutObjectLegalHold_non_existing_bucket": PutObjectLegalHold_non_existing_bucket, + "PutObjectLegalHold_non_existing_object": PutObjectLegalHold_non_existing_object, + "PutObjectLegalHold_invalid_body": PutObjectLegalHold_invalid_body, + "PutObjectLegalHold_invalid_status": PutObjectLegalHold_invalid_status, + "PutObjectLegalHold_unset_bucket_object_lock_config": PutObjectLegalHold_unset_bucket_object_lock_config, + "PutObjectLegalHold_success": PutObjectLegalHold_success, + "GetObjectLegalHold_non_existing_bucket": GetObjectLegalHold_non_existing_bucket, + "GetObjectLegalHold_non_existing_object": GetObjectLegalHold_non_existing_object, + "GetObjectLegalHold_disabled_lock": GetObjectLegalHold_disabled_lock, + "GetObjectLegalHold_unset_config": GetObjectLegalHold_unset_config, + "GetObjectLegalHold_success": GetObjectLegalHold_success, + "PutBucketAnalyticsConfiguration_not_implemented": PutBucketAnalyticsConfiguration_not_implemented, + "GetBucketAnalyticsConfiguration_not_implemented": GetBucketAnalyticsConfiguration_not_implemented, + "ListBucketAnalyticsConfiguration_not_implemented": ListBucketAnalyticsConfiguration_not_implemented, + "DeleteBucketAnalyticsConfiguration_not_implemented": DeleteBucketAnalyticsConfiguration_not_implemented, + "PutBucketEncryption_not_implemented": PutBucketEncryption_not_implemented, + "GetBucketEncryption_not_implemented": GetBucketEncryption_not_implemented, + "DeleteBucketEncryption_not_implemented": DeleteBucketEncryption_not_implemented, + "PutBucketIntelligentTieringConfiguration_not_implemented": PutBucketIntelligentTieringConfiguration_not_implemented, + "GetBucketIntelligentTieringConfiguration_not_implemented": GetBucketIntelligentTieringConfiguration_not_implemented, + "ListBucketIntelligentTieringConfiguration_not_implemented": ListBucketIntelligentTieringConfiguration_not_implemented, + "DeleteBucketIntelligentTieringConfiguration_not_implemented": DeleteBucketIntelligentTieringConfiguration_not_implemented, + "PutBucketInventoryConfiguration_not_implemented": PutBucketInventoryConfiguration_not_implemented, + "GetBucketInventoryConfiguration_not_implemented": GetBucketInventoryConfiguration_not_implemented, + "ListBucketInventoryConfiguration_not_implemented": ListBucketInventoryConfiguration_not_implemented, + "DeleteBucketInventoryConfiguration_not_implemented": DeleteBucketInventoryConfiguration_not_implemented, + "PutBucketLifecycleConfiguration_not_implemented": PutBucketLifecycleConfiguration_not_implemented, + "GetBucketLifecycleConfiguration_not_implemented": GetBucketLifecycleConfiguration_not_implemented, + "DeleteBucketLifecycle_not_implemented": DeleteBucketLifecycle_not_implemented, + "PutBucketLogging_not_implemented": PutBucketLogging_not_implemented, + "GetBucketLogging_not_implemented": GetBucketLogging_not_implemented, + "PutBucketRequestPayment_not_implemented": PutBucketRequestPayment_not_implemented, + "GetBucketRequestPayment_not_implemented": GetBucketRequestPayment_not_implemented, + "PutBucketMetricsConfiguration_not_implemented": PutBucketMetricsConfiguration_not_implemented, + "GetBucketMetricsConfiguration_not_implemented": GetBucketMetricsConfiguration_not_implemented, + "ListBucketMetricsConfigurations_not_implemented": ListBucketMetricsConfigurations_not_implemented, + "DeleteBucketMetricsConfiguration_not_implemented": DeleteBucketMetricsConfiguration_not_implemented, + "PutBucketReplication_not_implemented": PutBucketReplication_not_implemented, + "GetBucketReplication_not_implemented": GetBucketReplication_not_implemented, + "DeleteBucketReplication_not_implemented": DeleteBucketReplication_not_implemented, + "PutPublicAccessBlock_not_implemented": PutPublicAccessBlock_not_implemented, + "GetPublicAccessBlock_not_implemented": GetPublicAccessBlock_not_implemented, + "DeletePublicAccessBlock_not_implemented": DeletePublicAccessBlock_not_implemented, + "PutBucketNotificationConfiguratio_not_implemented": PutBucketNotificationConfiguratio_not_implemented, + "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, + "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, + "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, + "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, + "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, + "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, + "WORMProtection_bucket_object_lock_configuration_governance_mode": WORMProtection_bucket_object_lock_configuration_governance_mode, + "WORMProtection_bucket_object_lock_governance_bypass_delete": WORMProtection_bucket_object_lock_governance_bypass_delete, + "WORMProtection_bucket_object_lock_governance_bypass_delete_multiple": WORMProtection_bucket_object_lock_governance_bypass_delete_multiple, + "WORMProtection_object_lock_retention_compliance_locked": WORMProtection_object_lock_retention_compliance_locked, + "WORMProtection_object_lock_retention_governance_locked": WORMProtection_object_lock_retention_governance_locked, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_copy": WORMProtection_object_lock_retention_governance_bypass_overwrite_copy, + "WORMProtection_object_lock_retention_governance_bypass_overwrite_mp": WORMProtection_object_lock_retention_governance_bypass_overwrite_mp, + "WORMProtection_unable_to_overwrite_locked_object_put": WORMProtection_unable_to_overwrite_locked_object_put, + "WORMProtection_unable_to_overwrite_locked_object_copy": WORMProtection_unable_to_overwrite_locked_object_copy, + "WORMProtection_unable_to_overwrite_locked_object_mp": WORMProtection_unable_to_overwrite_locked_object_mp, + "WORMProtection_object_lock_retention_governance_bypass_delete": WORMProtection_object_lock_retention_governance_bypass_delete, + "WORMProtection_object_lock_retention_governance_bypass_delete_mul": WORMProtection_object_lock_retention_governance_bypass_delete_mul, + "WORMProtection_object_lock_legal_hold_locked": WORMProtection_object_lock_legal_hold_locked, + "WORMProtection_root_bypass_governance_retention_delete_object": WORMProtection_root_bypass_governance_retention_delete_object, + "PutObject_overwrite_dir_obj": PutObject_overwrite_dir_obj, + "PutObject_overwrite_file_obj": PutObject_overwrite_file_obj, + "PutObject_overwrite_file_obj_with_nested_obj": PutObject_overwrite_file_obj_with_nested_obj, + "PutObject_dir_obj_with_data": PutObject_dir_obj_with_data, + "PutObject_with_slashes": PutObject_with_slashes, + "PutObject_race_with_delete": PutObject_race_with_delete, + "CreateMultipartUpload_dir_obj": CreateMultipartUpload_dir_obj, + "IAM_user_access_denied": IAM_user_access_denied, + "IAM_userplus_access_denied": IAM_userplus_access_denied, + "IAM_userplus_CreateBucket": IAM_userplus_CreateBucket, + "IAM_admin_ChangeBucketOwner": IAM_admin_ChangeBucketOwner, + "IAM_ChangeBucketOwner_back_to_root": IAM_ChangeBucketOwner_back_to_root, + "IAM_ListBuckets": IAM_ListBuckets, + "IAM_CreateBucket_empty_owner_header": IAM_CreateBucket_empty_owner_header, + "IAM_CreateBucket_non_existing_user": IAM_CreateBucket_non_existing_user, + "IAM_CreateBucket_success": IAM_CreateBucket_success, + "AccessControl_default_ACL_user_access_denied": AccessControl_default_ACL_user_access_denied, + "AccessControl_default_ACL_userplus_access_denied": AccessControl_default_ACL_userplus_access_denied, + "AccessControl_default_ACL_admin_successful_access": AccessControl_default_ACL_admin_successful_access, + "AccessControl_bucket_resource_single_action": AccessControl_bucket_resource_single_action, + "AccessControl_bucket_resource_all_action": AccessControl_bucket_resource_all_action, + "AccessControl_single_object_resource_actions": AccessControl_single_object_resource_actions, + "AccessControl_multi_statement_policy": AccessControl_multi_statement_policy, + "AccessControl_bucket_ownership_to_user": AccessControl_bucket_ownership_to_user, + "AccessControl_root_PutBucketAcl": AccessControl_root_PutBucketAcl, + "AccessControl_user_PutBucketAcl_with_policy_access": AccessControl_user_PutBucketAcl_with_policy_access, + "AccessControl_copy_object_with_starting_slash_for_user": AccessControl_copy_object_with_starting_slash_for_user, + "AccessControl_PutObject_with_tagging_policy": AccessControl_PutObject_with_tagging_policy, + "AccessControl_PutObject_with_legal_hold_policy": AccessControl_PutObject_with_legal_hold_policy, + "AccessControl_PutObject_with_retention_policy": AccessControl_PutObject_with_retention_policy, + "AccessControl_CreateMultipartUpload_with_tagging_policy": AccessControl_CreateMultipartUpload_with_tagging_policy, + "AccessControl_CreateMultipartUpload_with_legal_hold_policy": AccessControl_CreateMultipartUpload_with_legal_hold_policy, + "AccessControl_CreateMultipartUpload_with_retention_policy": AccessControl_CreateMultipartUpload_with_retention_policy, + "AccessControl_CopyObject_with_tagging_policy": AccessControl_CopyObject_with_tagging_policy, + "AccessControl_CopyObject_with_legal_hold_policy": AccessControl_CopyObject_with_legal_hold_policy, + "AccessControl_CopyObject_with_retention_policy": AccessControl_CopyObject_with_retention_policy, + "AccessControl_policy_normalizes_object_key_for_get_put_delete": AccessControl_policy_normalizes_object_key_for_get_put_delete, + "PublicBucket_default_private_bucket": PublicBucket_default_private_bucket, + "PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy, + "PublicBucket_public_object_policy": PublicBucket_public_object_policy, + "PublicBucket_public_acl": PublicBucket_public_acl, + "PublicBucket_policy_deny_overrides_public_acl": PublicBucket_policy_deny_overrides_public_acl, + "PublicBucket_signed_streaming_payload": PublicBucket_signed_streaming_payload, + "PublicBucket_incorrect_sha256_hash": PublicBucket_incorrect_sha256_hash, + "PutBucketVersioning_non_existing_bucket": PutBucketVersioning_non_existing_bucket, + "PutBucketVersioning_invalid_status": PutBucketVersioning_invalid_status, + "PutBucketVersioning_success_enabled": PutBucketVersioning_success_enabled, + "PutBucketVersioning_success_suspended": PutBucketVersioning_success_suspended, + "GetBucketVersioning_non_existing_bucket": GetBucketVersioning_non_existing_bucket, + "GetBucketVersioning_empty_response": GetBucketVersioning_empty_response, + "GetBucketVersioning_success": GetBucketVersioning_success, + "Versioning_DeleteBucket_not_empty": Versioning_DeleteBucket_not_empty, + "Versioning_PutObject_suspended_null_versionId_obj": Versioning_PutObject_suspended_null_versionId_obj, + "Versioning_PutObject_null_versionId_obj": Versioning_PutObject_null_versionId_obj, + "Versioning_PutObject_overwrite_null_versionId_obj": Versioning_PutObject_overwrite_null_versionId_obj, + "Versioning_PutObject_success": Versioning_PutObject_success, + "Versioning_CopyObject_invalid_versionId": Versioning_CopyObject_invalid_versionId, + "Versioning_CopyObject_success": Versioning_CopyObject_success, + "Versioning_CopyObject_non_existing_version_id": Versioning_CopyObject_non_existing_version_id, + "Versioning_CopyObject_from_an_object_version": Versioning_CopyObject_from_an_object_version, + "Versioning_CopyObject_special_chars": Versioning_CopyObject_special_chars, + "Versioning_HeadObject_invalid_versionId": Versioning_HeadObject_invalid_versionId, + "Versioning_HeadObject_non_existing_object_version": Versioning_HeadObject_non_existing_object_version, + "Versioning_HeadObject_invalid_parent": Versioning_HeadObject_invalid_parent, + "Versioning_HeadObject_success": Versioning_HeadObject_success, + "Versioning_HeadObject_without_versionId": Versioning_HeadObject_without_versionId, + "Versioning_HeadObject_delete_marker": Versioning_HeadObject_delete_marker, + "Versioning_GetObject_invalid_versionId": Versioning_GetObject_invalid_versionId, + "Versioning_GetObject_non_existing_object_version": Versioning_GetObject_non_existing_object_version, + "Versioning_GetObject_success": Versioning_GetObject_success, + "Versioning_GetObject_delete_marker_without_versionId": Versioning_GetObject_delete_marker_without_versionId, + "Versioning_GetObject_delete_marker": Versioning_GetObject_delete_marker, + "Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj, + "Versioning_PutObjectTagging_invalid_versionId": Versioning_PutObjectTagging_invalid_versionId, + "Versioning_PutObjectTagging_non_existing_object_version": Versioning_PutObjectTagging_non_existing_object_version, + "Versioning_PutGetDeleteObjectTagging_delete_marker": Versioning_PutGetDeleteObjectTagging_delete_marker, + "Versioning_GetObjectTagging_invalid_versionId": Versioning_GetObjectTagging_invalid_versionId, + "Versioning_GetObjectTagging_non_existing_object_version": Versioning_GetObjectTagging_non_existing_object_version, + "Versioning_DeleteObjectTagging_invalid_versionId": Versioning_DeleteObjectTagging_invalid_versionId, + "Versioning_DeleteObjectTagging_non_existing_object_version": Versioning_DeleteObjectTagging_non_existing_object_version, + "Versioning_PutGetDeleteObjectTagging_success": Versioning_PutGetDeleteObjectTagging_success, + "Versioning_GetObjectAttributes_invalid_versionId": Versioning_GetObjectAttributes_invalid_versionId, + "Versioning_GetObjectAttributes_object_version": Versioning_GetObjectAttributes_object_version, + "Versioning_GetObjectAttributes_delete_marker": Versioning_GetObjectAttributes_delete_marker, + "Versioning_DeleteObject_invalid_versionId": Versioning_DeleteObject_invalid_versionId, + "Versioning_DeleteObject_delete_object_version": Versioning_DeleteObject_delete_object_version, + "Versioning_DeleteObject_non_existing_object": Versioning_DeleteObject_non_existing_object, + "Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker, + "Versioning_Delete_null_versionId_object": Versioning_Delete_null_versionId_object, + "Versioning_DeleteObject_nested_dir_object": Versioning_DeleteObject_nested_dir_object, + "Versioning_DeleteObject_non_existing_objects": Versioning_DeleteObject_non_existing_objects, + "Versioning_DeleteObject_suspended": Versioning_DeleteObject_suspended, + "Versioning_DeleteObjects_success": Versioning_DeleteObjects_success, + "Versioning_DeleteObjects_delete_deleteMarkers": Versioning_DeleteObjects_delete_deleteMarkers, + "ListObjectVersions_non_existing_bucket": ListObjectVersions_non_existing_bucket, + "ListObjectVersions_negative_max_keys": ListObjectVersions_negative_max_keys, + "ListObjectVersions_list_single_object_versions": ListObjectVersions_list_single_object_versions, + "ListObjectVersions_list_multiple_object_versions": ListObjectVersions_list_multiple_object_versions, + "ListObjectVersions_multiple_object_versions_truncated": ListObjectVersions_multiple_object_versions_truncated, + "ListObjectVersions_with_delete_markers": ListObjectVersions_with_delete_markers, + "ListObjectVersions_containing_null_versionId_obj": ListObjectVersions_containing_null_versionId_obj, + "ListObjectVersions_single_null_versionId_object": ListObjectVersions_single_null_versionId_object, + "ListObjectVersions_checksum": ListObjectVersions_checksum, + "Versioning_Multipart_Upload_success": Versioning_Multipart_Upload_success, + "Versioning_Multipart_Upload_overwrite_an_object": Versioning_Multipart_Upload_overwrite_an_object, + "Versioning_UploadPartCopy_invalid_versionId": Versioning_UploadPartCopy_invalid_versionId, + "Versioning_UploadPartCopy_non_existing_versionId": Versioning_UploadPartCopy_non_existing_versionId, + "Versioning_UploadPartCopy_from_an_object_version": Versioning_UploadPartCopy_from_an_object_version, + "Versioning_object_lock_not_enabled_on_bucket_creation": Versioning_object_lock_not_enabled_on_bucket_creation, + "Versioning_Enable_object_lock": Versioning_Enable_object_lock, + "Versioning_status_switch_to_suspended_with_object_lock": Versioning_status_switch_to_suspended_with_object_lock, + "Versioning_PutObjectRetention_invalid_versionId": Versioning_PutObjectRetention_invalid_versionId, + "Versioning_PutObjectRetention_non_existing_object_version": Versioning_PutObjectRetention_non_existing_object_version, + "Versioning_GetObjectRetention_invalid_versionId": Versioning_GetObjectRetention_invalid_versionId, + "Versioning_GetObjectRetention_non_existing_object_version": Versioning_GetObjectRetention_non_existing_object_version, + "Versioning_Put_GetObjectRetention_delete_marker": Versioning_Put_GetObjectRetention_delete_marker, + "Versioning_Put_GetObjectRetention_success": Versioning_Put_GetObjectRetention_success, + "Versioning_PutObjectLegalHold_invalid_versionId": Versioning_PutObjectLegalHold_invalid_versionId, + "Versioning_PutObjectLegalHold_non_existing_object_version": Versioning_PutObjectLegalHold_non_existing_object_version, + "Versioning_GetObjectLegalHold_invalid_versionId": Versioning_GetObjectLegalHold_invalid_versionId, + "Versioning_GetObjectLegalHold_non_existing_object_version": Versioning_GetObjectLegalHold_non_existing_object_version, + "Versioning_PutGetObjectLegalHold_delete_marker": Versioning_PutGetObjectLegalHold_delete_marker, + "Versioning_Put_GetObjectLegalHold_success": Versioning_Put_GetObjectLegalHold_success, + "Versioning_WORM_obj_version_locked_with_legal_hold": Versioning_WORM_obj_version_locked_with_legal_hold, + "Versioning_WORM_obj_version_locked_with_governance_retention": Versioning_WORM_obj_version_locked_with_governance_retention, + "Versioning_WORM_obj_version_locked_with_compliance_retention": Versioning_WORM_obj_version_locked_with_compliance_retention, + "Versioning_WORM_delete_marker_locked_object_legal_hold": Versioning_WORM_delete_marker_locked_object_legal_hold, + "Versioning_WORM_delete_marker_locked_object_governance_retention": Versioning_WORM_delete_marker_locked_object_governance_retention, + "Versioning_WORM_delete_marker_locked_object_compliance_retention": Versioning_WORM_delete_marker_locked_object_compliance_retention, + "Versioning_WORM_PutObject_overwrite_locked_object": Versioning_WORM_PutObject_overwrite_locked_object, + "Versioning_WORM_CopyObject_overwrite_locked_object": Versioning_WORM_CopyObject_overwrite_locked_object, + "Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object": Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object, + "Versioning_WORM_remove_delete_marker_under_bucket_default_retention": Versioning_WORM_remove_delete_marker_under_bucket_default_retention, + "Versioning_AccessControl_GetObjectVersion": Versioning_AccessControl_GetObjectVersion, + "Versioning_AccessControl_HeadObjectVersion": Versioning_AccessControl_HeadObjectVersion, + "Versioning_AccessControl_object_tagging_policy": Versioning_AccessControl_object_tagging_policy, + "Versioning_AccessControl_DeleteObject_policy": Versioning_AccessControl_DeleteObject_policy, + "Versioning_AccessControl_GetObjectAttributes_policy": Versioning_AccessControl_GetObjectAttributes_policy, + "Versioning_concurrent_upload_object": Versioning_concurrent_upload_object, + "RouterPutPartNumberWithoutUploadId": RouterPutPartNumberWithoutUploadId, + "RouterPostRoot": RouterPostRoot, + "RouterPostObjectWithoutQuery": RouterPostObjectWithoutQuery, + "RouterPUTObjectOnlyUploadId": RouterPUTObjectOnlyUploadId, + "RouterGetUploadsWithKey": RouterGetUploadsWithKey, + "RouterCopySourceNotAllowed": RouterCopySourceNotAllowed, + "RouterListVersionsWithKey": RouterListVersionsWithKey, + "UnsignedStreaminPayloadTrailer_malformed_trailer": UnsignedStreaminPayloadTrailer_malformed_trailer, + "UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length": UnsignedStreamingPayloadTrailer_missing_invalid_dec_content_length, + "UnsignedStreamingPayloadTrailer_invalid_trailing_checksum": UnsignedStreamingPayloadTrailer_invalid_trailing_checksum, + "UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum": UnsignedStreamingPayloadTrailer_incorrect_trailing_checksum, + "UnsignedStreamingPayloadTrailer_multiple_checksum_headers": UnsignedStreamingPayloadTrailer_multiple_checksum_headers, + "UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch": UnsignedStreamingPayloadTrailer_sdk_algo_and_trailer_mismatch, + "UnsignedStreamingPayloadTrailer_incomplete_body": UnsignedStreamingPayloadTrailer_incomplete_body, + "UnsignedStreamingPayloadTrailer_invalid_chunk_size": UnsignedStreamingPayloadTrailer_invalid_chunk_size, + "UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch": UnsignedStreamingPayloadTrailer_content_length_payload_size_mismatch, + "UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme": UnsignedStreamingPayloadTrailer_no_trailer_should_calculate_crc64nvme, + "UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers": UnsignedStreamingPayloadTrailer_no_payload_trailer_only_headers, + "UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer": UnsignedStreamingPayloadTrailer_success_both_sdk_algo_and_trailer, + "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_composite_checksum, + "UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object": UnsignedStreamingPayloadTrailer_UploadPart_no_trailer_full_object, + "UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch": UnsignedStreamingPayloadTrailer_UploadPart_trailer_and_mp_algo_mismatch, + "UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer": UnsignedStreamingPayloadTrailer_UploadPart_success_with_trailer, + "UnsignedStreamingPayloadTrailer_not_allowed": UnsignedStreamingPayloadTrailer_not_allowed, + "SignedStreamingPayload_invalid_encoding": SignedStreamingPayload_invalid_encoding, + "SignedStreamingPayload_invalid_chunk_size": SignedStreamingPayload_invalid_chunk_size, + "SignedStreamingPayload_decoded_content_length_mismatch": SignedStreamingPayload_decoded_content_length_mismatch, + "SignedStreamingPayloadTrailer_malformed_trailer": SignedStreamingPayloadTrailer_malformed_trailer, + "SignedStreamingPayloadTrailer_incomplete_body": SignedStreamingPayloadTrailer_incomplete_body, + "SignedStreamingPayloadTrailer_missing_x_amz_trailer_header": SignedStreamingPayloadTrailer_missing_x_amz_trailer_header, + "SignedStreamingPayloadTrailer_invalid_checksum": SignedStreamingPayloadTrailer_invalid_checksum, + "SignedStreamingPayloadTrailer_bad_digest": SignedStreamingPayloadTrailer_bad_digest, + "SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success, + "NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl, + "NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl, + "Server_large_http_header": Server_large_http_header, + "PostObject_invalid_content_type": PostObject_invalid_content_type, + "PostObject_missing_boundary": PostObject_missing_boundary, + "PostObject_partial_auth_fields": PostObject_partial_auth_fields, + "PostObject_invalid_algorithm": PostObject_invalid_algorithm, + "PostObject_invalid_date": PostObject_invalid_date, + "PostObject_invalid_credential_format": PostObject_invalid_credential_format, + "PostObject_incorrect_region": PostObject_incorrect_region, + "PostObject_non_existing_access_key": PostObject_non_existing_access_key, + "PostObject_signature_mismatch": PostObject_signature_mismatch, + "PostObject_expired_due_to_date": PostObject_expired_due_to_date, + "PostObject_access_denied": PostObject_access_denied, + "PostObject_invalid_object_names": PostObject_invalid_object_names, + "PostObject_policy_access_control": PostObject_policy_access_control, + "PostObject_policy_expired": PostObject_policy_expired, + "PostObject_invalid_policy_document": PostObject_invalid_policy_document, + "PostObject_policy_condition_key_mismatch": PostObject_policy_condition_key_mismatch, + "PostObject_policy_extra_field": PostObject_policy_extra_field, + "PostObject_policy_missing_bucket_condition": PostObject_policy_missing_bucket_condition, + "PostObject_policy_content_length_too_large": PostObject_policy_content_length_too_large, + "PostObject_policy_content_length_too_small": PostObject_policy_content_length_too_small, + "PostObject_success": PostObject_success, + "PostObject_success_status_200": PostObject_success_status_200, + "PostObject_success_status_201": PostObject_success_status_201, + "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, + "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, + "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, + "PostObject_invalid_tagging": PostObject_invalid_tagging, + "PostObject_success_with_tagging": PostObject_success_with_tagging, + "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, + "PostObject_invalid_checksum_algorithm": PostObject_invalid_checksum_algorithm, + "PostObject_multiple_checksum_headers": PostObject_multiple_checksum_headers, + "PostObject_checksums_success": PostObject_checksums_success, + "PostObject_success_double_dash_boundary": PostObject_success_double_dash_boundary, } } diff --git a/tests/integration/iam_access_control.go b/tests/integration/iam_access_control.go new file mode 100644 index 00000000..1c634ca1 --- /dev/null +++ b/tests/integration/iam_access_control.go @@ -0,0 +1,2843 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +// This file tests authorization (allow/deny) decisions for the standalone +// IAM/STS service: identity-based inline policies (user and role), role +// trust policies, and condition evaluation across both. It deliberately does +// not test policy-document validation, malformed input, or other API +// surface already covered by iam_put_user_policy.go/iam_create_role.go/etc. +// +// Session/session-policy scope: AssumeRoleWithWebIdentity is the only action +// that mints a session in this codebase, and a real successful call requires +// the server to fetch a real JWKS from the token's issuer and verify a real +// cryptographic signature. The SSRF guard in iamutil's OIDC fetch path +// (isDisallowedFetchTarget) unconditionally rejects loopback, private +// (RFC1918), and link-local addresses as fetch targets — so no JWKS server +// this test process stands up on the same machine can ever be reachable, +// and a real successful AssumeRoleWithWebIdentity is unreachable from this +// suite by design. Every test below that needs to observe a trust-policy +// "Allowed" decision instead uses the same technique the rest of this +// package's AssumeRoleWithWebIdentity tests already use (see +// IAMAssumeRoleWithWebIdentity_oaud_condition_matches in +// iam_assume_role_with_web_identity.go): point the provider at a loopback +// URL and observe that evaluation reaches the network-dependent signature +// step (InvalidIdentityTokenIDPCommunicationError) rather than being +// rejected earlier by trust evaluation itself (AccessDenied or the +// claims-stage InvalidIdentityToken). Reaching that step is only possible +// once Principal, Condition, and audience matching have all already +// succeeded, so it's a reliable, deterministic proxy for "Allowed" — but it +// means this suite cannot exercise anything that requires an actual minted +// session (session-policy intersection, a live session calling further IAM +// actions). + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/url" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every ARN the gateway issues is scoped to this single fixed account. +const testAccountID = "000000000000" + +const ( + actGetUser = "iam:GetUser" + actListUsers = "iam:ListUsers" + actListUserPolicies = "iam:ListUserPolicies" + actGetUserPolicy = "iam:GetUserPolicy" + actDeleteUserPolicy = "iam:DeleteUserPolicy" + actPutUserPolicy = "iam:PutUserPolicy" + actCreateUser = "iam:CreateUser" + actGetRole = "iam:GetRole" + actListRolePolicies = "iam:ListRolePolicies" +) + +// defaultTestAudience is the OIDC ClientIDList/token-audience pair used by +// every trust-policy test below that isn't specifically exercising audience +// matching itself +var defaultTestAudience = []string{"client1"} + +// IAMAccessControl_ImplicitDenyNoMatchingPolicy verifies a caller with no +// policies at all is denied by default (no Allow ever exists to grant +// anything). +func IAMAccessControl_ImplicitDenyNoMatchingPolicy(s *S3Conf) error { + testName := "IAMAccessControl_ImplicitDenyNoMatchingPolicy" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", nil) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_AllowGrantsMatchingRequest verifies a single matching +// Allow statement grants the request, and that the response actually +// reflects the target resource (not just a nil error) — proving the call +// was genuinely authorized and executed, not accidentally short-circuited. +func IAMAccessControl_AllowGrantsMatchingRequest(s *S3Conf) error { + testName := "IAMAccessControl_AllowGrantsMatchingRequest" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + out, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil { + return err + } + if out == nil || out.User == nil || aws.ToString(out.User.UserName) != targetName { + return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, out) + } + return nil + }) +} + +// IAMAccessControl_NonMatchingStatementDoesNotGrant verifies a policy whose +// only statement covers a *different* action does not grant the tested +// action — a non-matching statement contributes nothing, it isn't a +// fallback Allow. +func IAMAccessControl_NonMatchingStatementDoesNotGrant(s *S3Conf) error { + testName := "IAMAccessControl_NonMatchingStatementDoesNotGrant" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actListRolePolicies, Resource: "*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ExplicitDenyOverridesAllow verifies an explicit Deny +// always wins over a matching Allow, regardless of statement order or +// whether the Deny is in the same policy document or a separate one. +func IAMAccessControl_ExplicitDenyOverridesAllow(s *S3Conf) error { + testName := "IAMAccessControl_ExplicitDenyOverridesAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + allow := accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn} + deny := accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn} + + cases := []struct { + name string + policies map[string]string + }{ + {"deny after allow, same document", map[string]string{"p": policyDoc(allow, deny)}}, + {"deny before allow, same document", map[string]string{"p": policyDoc(deny, allow)}}, + {"allow and deny in separate documents", map[string]string{"allow": policyDoc(allow), "deny": policyDoc(deny)}}, + } + for _, tc := range cases { + if err := func() error { + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", tc.policies) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_MultipleStatementsEvaluatedIndependently verifies two +// statements in one policy document, covering two different actions, are +// each evaluated on their own terms: both grant their own action, and +// neither grants the other's. +func IAMAccessControl_MultipleStatementsEvaluatedIndependently(s *S3Conf) error { + testName := "IAMAccessControl_MultipleStatementsEvaluatedIndependently" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Sid: "AllowGet", Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Sid: "AllowListPolicies", Effect: "Allow", Action: actListUserPolicies, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actGetUser, targetArn, err) != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil { + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + } + // Neither statement covers DeleteUserPolicy. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err) + }) +} + +// IAMAccessControl_MultipleInlinePoliciesCombinedAllow verifies two separate +// inline policies attached to the same user are combined: a statement in +// either one is enough to grant its action. +func IAMAccessControl_MultipleInlinePoliciesCombinedAllow(s *S3Conf) error { + testName := "IAMAccessControl_MultipleInlinePoliciesCombinedAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policies := map[string]string{ + "policy-a": policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}), + "policy-b": policyDoc(accessStatement{Effect: "Allow", Action: actListUserPolicies, Resource: targetArn}), + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + }) +} + +// IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins verifies a Deny in +// one inline policy overrides an Allow in a *different* inline policy on the +// same user — combination is not "most permissive wins", explicit Deny is +// global across every attached policy. +func IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins(s *S3Conf) error { + testName := "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policies := map[string]string{ + "allow-everything": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"}), + "deny-get-user": policyDoc(accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn}), + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies) + if err != nil { + return err + } + defer cleanupCaller() + + // The broad Allow still grants an unrelated action... + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actListUserPolicies, targetArn, err) + } + // ...but the specific Deny still wins for the action it names. + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies verifies an +// Allow statement present in a policy but not covering the tested +// action/resource contributes nothing — the request is still implicitly +// denied, not accidentally granted just because *some* Allow exists +// somewhere in the document. +func IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies(s *S3Conf) error { + testName := "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + otherName, _, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/" + otherName}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow verifies +// a Deny statement that doesn't cover the tested action/resource simply +// doesn't apply — it does not somehow block an unrelated Allow elsewhere in +// the same policy. +func IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow(s *S3Conf) error { + testName := "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ActionMatchingVariants covers exact, wildcard, array, and +// case-insensitive Action matching, all against the same target resource so +// only the Action dimension varies row to row. +func IAMAccessControl_ActionMatchingVariants(s *S3Conf) error { + testName := "IAMAccessControl_ActionMatchingVariants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + action any + wantAllowed bool + }{ + {"exact action match", "iam:GetUser", true}, + {"service wildcard iam:*", "iam:*", true}, + {"operation prefix wildcard iam:Get*", "iam:Get*", true}, + {"suffix wildcard iam:*User", "iam:*User", true}, + {"single-char ? wildcard", "iam:GetUse?", true}, + {"action present in an array", []string{"iam:ListUsers", "iam:GetUser"}, true}, + {"case-insensitive policy action", "IAM:GETUSER", true}, + {"nonmatching action", "iam:PutUserPolicy", false}, + {"nonmatching prefix wildcard", "iam:List*", false}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: tc.action, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ActionAllowOneDenyAnotherByOmission verifies a policy +// granting exactly one action grants only that action — a sibling action +// against the very same resource is still denied. +func IAMAccessControl_ActionAllowOneDenyAnotherByOmission(s *S3Conf) error { + testName := "IAMAccessControl_ActionAllowOneDenyAnotherByOmission" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actListUserPolicies, targetArn, err) + }) +} + +// IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow verifies an +// explicit Deny for one specific action carves it out of an otherwise +// all-encompassing wildcard Allow, without affecting any other action the +// wildcard still covers. +func IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow(s *S3Conf) error { + testName := "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: "iam:*", Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err) + }) +} + +// IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded verifies an +// Allow+NotAction statement grants every action *except* the ones listed — +// the excluded action is denied, a nonexcluded one is allowed. +func IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded(s *S3Conf) error { + testName := "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc(accessStatement{Effect: "Allow", NotAction: []string{actListUsers, actDeleteUserPolicy}, Resource: "*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + // GetUser is not in the NotAction list, so it's covered by the Allow. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + // ListUsers is excluded via NotAction, so the statement doesn't cover it. + _, err = listIAMUsers(caller.client, &iam.ListUsersInput{}) + return wantDenied(caller.arn, actListUsers, "*", err) + }) +} + +// IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded verifies the +// interaction between an Action-based Allow and a NotAction-based Deny: a +// broad Allow grants everything, but a Deny+NotAction statement denies every +// action *except* the one named — net effect, only that one action remains +// allowed. +func IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded(s *S3Conf) error { + testName := "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"}, + accessStatement{Effect: "Deny", NotAction: actGetUser, Resource: "*"}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + // GetUser is excluded from the Deny's NotAction coverage, so only the + // Allow applies to it. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + // Every other action is covered by the Deny (it's not GetUser). + _, err = listIAMUsers(caller.client, &iam.ListUsersInput{}) + return wantDenied(caller.arn, actListUsers, "*", err) + }) +} + +// IAMAccessControl_ResourceMatchingVariants covers exact, wildcard, and +// array Resource matching for both a user and a role target. +func IAMAccessControl_ResourceMatchingVariants(s *S3Conf) error { + testName := "IAMAccessControl_ResourceMatchingVariants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetUserName, targetUserArn, cleanupUser, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupUser() + targetRoleName, targetRoleArn, cleanupRole, err := newTargetRole(root) + if err != nil { + return err + } + defer cleanupRole() + pathUserName, pathUserArn, cleanupPathUser, err := newTargetUserWithPath(root, "/ac-team/") + if err != nil { + return err + } + defer cleanupPathUser() + otherName, _, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + run := func(name, action, resourcePattern, wantResource string, call func(client *iam.Client) error) error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: action, Resource: resourcePattern}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + if err := wantAllowed(caller.arn, action, wantResource, call(caller.client)); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("exact user ARN", actGetUser, targetUserArn, targetUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)}) + return err + }); err != nil { + return err + } + if err := run("exact role ARN", actGetRole, targetRoleArn, targetRoleArn, func(c *iam.Client) error { + _, err := getIAMRole(c, targetRoleName) + return err + }); err != nil { + return err + } + if err := run("wildcard resource ARN", actGetUser, "*", targetUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)}) + return err + }); err != nil { + return err + } + if err := run("resource path wildcard", actGetUser, "arn:aws:iam::"+testAccountID+":user/ac-team/*", pathUserArn, func(c *iam.Client) error { + _, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(pathUserName)}) + return err + }); err != nil { + return err + } + + // Multiple resources in an array: both named ARNs are granted, a third + // (equally valid) resource is not. + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: []string{targetUserArn, pathUserArn}}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return fmt.Errorf("resource array: %w", err) + } + defer cleanupCaller() + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetUserName)}); wantAllowed(caller.arn, actGetUser, targetUserArn, err) != nil { + return fmt.Errorf("resource array, first entry: %w", wantAllowed(caller.arn, actGetUser, targetUserArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(pathUserName)}); wantAllowed(caller.arn, actGetUser, pathUserArn, err) != nil { + return fmt.Errorf("resource array, second entry: %w", wantAllowed(caller.arn, actGetUser, pathUserArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, "(not in array)", err) != nil { + return fmt.Errorf("resource array, nonmatching entry: %w", wantDenied(caller.arn, actGetUser, "(not in array)", err)) + } + + // Nonmatching resource: exact grant to one user does not cover another. + exactPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetUserArn}) + exactCaller, cleanupExact, err := newAccessControlCaller(root, s, "", map[string]string{"p": exactPolicy}) + if err != nil { + return fmt.Errorf("nonmatching resource denied: %w", err) + } + defer cleanupExact() + _, err = getIAMUser(exactCaller.client, &iam.GetUserInput{UserName: aws.String(otherName)}) + if err := wantDenied(exactCaller.arn, actGetUser, targetUserArn, err); err != nil { + return fmt.Errorf("nonmatching resource denied: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ResourceOneAllowedOneDeniedSameAction verifies a +// resource-scoped Allow grants the same action against its named resource +// but denies it against an equally-valid, unrelated resource. +func IAMAccessControl_ResourceOneAllowedOneDeniedSameAction(s *S3Conf) error { + testName := "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction" + return iamActionHandler(s, testName, func(root *iam.Client) error { + allowedName, allowedArn, cleanupAllowed, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupAllowed() + deniedName, deniedArn, cleanupDenied, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupDenied() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: allowedArn}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(allowedName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, allowedArn, err) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(deniedName)}) + return wantDenied(caller.arn, actGetUser, deniedArn, err) + }) +} + +// IAMAccessControl_ResourceWildcardRequiredForListAction verifies a +// List-type action (whose only valid resource-level scope is "*", per +// resourceForAction's classification) is denied by a resource-scoped grant +// naming a specific entity, and allowed once the grant uses "*". +func IAMAccessControl_ResourceWildcardRequiredForListAction(s *S3Conf) error { + testName := "IAMAccessControl_ResourceWildcardRequiredForListAction" + return iamActionHandler(s, testName, func(root *iam.Client) error { + _, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + scoped := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: targetArn}) + scopedCaller, cleanupScoped, err := newAccessControlCaller(root, s, "", map[string]string{"p": scoped}) + if err != nil { + return err + } + defer cleanupScoped() + _, err = listIAMUsers(scopedCaller.client, &iam.ListUsersInput{}) + if err := wantDenied(scopedCaller.arn, actListUsers, "*", err); err != nil { + return fmt.Errorf("resource-scoped grant: %w", err) + } + + wildcard := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: "*"}) + wildcardCaller, cleanupWildcard, err := newAccessControlCaller(root, s, "", map[string]string{"p": wildcard}) + if err != nil { + return err + } + defer cleanupWildcard() + _, err = listIAMUsers(wildcardCaller.client, &iam.ListUsersInput{}) + if err := wantAllowed(wildcardCaller.arn, actListUsers, "*", err); err != nil { + return fmt.Errorf("wildcard grant: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow verifies a +// Deny scoped to one specific resource carves it out of a broader +// Resource:"*" Allow, without affecting any other resource the Allow still +// covers. +func IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow(s *S3Conf) error { + testName := "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + blockedName, blockedArn, cleanupBlocked, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupBlocked() + otherName, otherArn, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: blockedArn}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); err != nil { + return wantAllowed(caller.arn, actGetUser, otherArn, err) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(blockedName)}) + return wantDenied(caller.arn, actGetUser, blockedArn, err) + }) +} + +// IAMAccessControl_NotResourceExcludesTarget verifies both directions of +// NotResource: an Allow+NotResource statement applies to every resource +// *except* the excluded one, while a Deny+NotResource statement (layered +// over a broader baseline Allow) denies every resource *except* the +// excluded one — the excluded resource's fate inverts between the two. +func IAMAccessControl_NotResourceExcludesTarget(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceExcludesTarget" + return iamActionHandler(s, testName, func(root *iam.Client) error { + user1Name, user1Arn, cleanup1, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanup1() + user2Name, user2Arn, cleanup2, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanup2() + + // Allow + NotResource[user2]: user1 allowed, user2 (excluded) denied. + allowPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: user2Arn}) + allowCaller, cleanupAllow, err := newAccessControlCaller(root, s, "", map[string]string{"p": allowPolicy}) + if err != nil { + return err + } + defer cleanupAllow() + if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantAllowed(allowCaller.arn, actGetUser, user1Arn, err) != nil { + return fmt.Errorf("Allow+NotResource, non-excluded: %w", wantAllowed(allowCaller.arn, actGetUser, user1Arn, err)) + } + if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantDenied(allowCaller.arn, actGetUser, user2Arn, err) != nil { + return fmt.Errorf("Allow+NotResource, excluded: %w", wantDenied(allowCaller.arn, actGetUser, user2Arn, err)) + } + + // Baseline Allow(*) + Deny+NotResource[user2]: user1 denied (Deny + // covers it, since it's not the excluded one), user2 allowed (Deny + // doesn't cover the excluded resource, so only the baseline Allow + // applies to it). + denyPolicy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"}, + accessStatement{Effect: "Deny", Action: actGetUser, NotResource: user2Arn}, + ) + denyCaller, cleanupDeny, err := newAccessControlCaller(root, s, "", map[string]string{"p": denyPolicy}) + if err != nil { + return err + } + defer cleanupDeny() + if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantDenied(denyCaller.arn, actGetUser, user1Arn, err) != nil { + return fmt.Errorf("Deny+NotResource, non-excluded: %w", wantDenied(denyCaller.arn, actGetUser, user1Arn, err)) + } + if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantAllowed(denyCaller.arn, actGetUser, user2Arn, err) != nil { + return fmt.Errorf("Deny+NotResource, excluded: %w", wantAllowed(denyCaller.arn, actGetUser, user2Arn, err)) + } + return nil + }) +} + +// IAMAccessControl_NotResourceMultipleExcludedResources verifies a +// NotResource array excludes every listed resource, not just the first. +func IAMAccessControl_NotResourceMultipleExcludedResources(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceMultipleExcludedResources" + return iamActionHandler(s, testName, func(root *iam.Client) error { + includedName, includedArn, cleanupIncluded, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupIncluded() + excluded1Name, excluded1Arn, cleanupExcluded1, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupExcluded1() + excluded2Name, excluded2Arn, cleanupExcluded2, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupExcluded2() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: []string{excluded1Arn, excluded2Arn}}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil { + return fmt.Errorf("non-excluded resource: %w", wantAllowed(caller.arn, actGetUser, includedArn, err)) + } + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded1Name)}); wantDenied(caller.arn, actGetUser, excluded1Arn, err) != nil { + return fmt.Errorf("first excluded resource: %w", wantDenied(caller.arn, actGetUser, excluded1Arn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded2Name)}) + if err := wantDenied(caller.arn, actGetUser, excluded2Arn, err); err != nil { + return fmt.Errorf("second excluded resource: %w", err) + } + return nil + }) +} + +// IAMAccessControl_NotResourceWildcardExclusion verifies NotResource +// supports the same wildcard glob Resource does: excluding a whole +// path-prefix pattern excludes every resource under it, not just one exact +// ARN. +func IAMAccessControl_NotResourceWildcardExclusion(s *S3Conf) error { + testName := "IAMAccessControl_NotResourceWildcardExclusion" + return iamActionHandler(s, testName, func(root *iam.Client) error { + excludedName, excludedArn, cleanupExcluded, err := newTargetUserWithPath(root, "/ac-excluded/") + if err != nil { + return err + } + defer cleanupExcluded() + includedName, includedArn, cleanupIncluded, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupIncluded() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: "arn:aws:iam::" + testAccountID + ":user/ac-excluded/*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil { + return fmt.Errorf("outside excluded path: %w", wantAllowed(caller.arn, actGetUser, includedArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excludedName)}) + if err := wantDenied(caller.arn, actGetUser, excludedArn, err); err != nil { + return fmt.Errorf("inside excluded path: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionStringOperators covers the full String +// condition-operator family against aws:username — a key this suite fully +// controls on both sides (the caller's actual username, and the policy's +// expected value), giving every row a deterministic outcome. +func IAMAccessControl_ConditionStringOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionStringOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + callerName string + condition func(callerName string) json.RawMessage + wantAllowed bool + }{ + {"StringEquals exact match", "ac-str-alice-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringEquals", "aws:username", c) }, true}, + {"StringEquals nonmatch", "ac-str-bob-" + genRandString(6), + func(string) json.RawMessage { return cond("StringEquals", "aws:username", "someone-else") }, false}, + {"StringNotEquals matches when different", "ac-str-carol-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotEquals", "aws:username", "someone-else") }, true}, + {"StringNotEquals denies when equal", "ac-str-dave-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringNotEquals", "aws:username", c) }, false}, + {"StringEqualsIgnoreCase matches different case", "ac-str-erin-" + genRandString(6), + func(c string) json.RawMessage { return cond("StringEqualsIgnoreCase", "aws:username", upperASCII(c)) }, true}, + {"StringNotEqualsIgnoreCase denies matching case-insensitively", "ac-str-frank-" + genRandString(6), + func(c string) json.RawMessage { + return cond("StringNotEqualsIgnoreCase", "aws:username", upperASCII(c)) + }, false}, + {"StringLike prefix wildcard", "ac-str-wild-prefix-" + genRandString(6), + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-prefix-*") }, true}, + {"StringLike suffix wildcard", "ac-str-wild-suffix-suf", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "*-suf") }, true}, + {"StringLike middle wildcard", "ac-str-wild-mid-zzz-tail", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-mid-*-tail") }, true}, + {"StringLike ? wildcard", "ac-str-wld-abc", + func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wld-a?c") }, true}, + {"StringLike nonmatch", "ac-str-nomatch-" + genRandString(6), + func(string) json.RawMessage { return cond("StringLike", "aws:username", "totally-different-*") }, false}, + {"StringNotLike denies matching wildcard", "ac-str-notlike-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "ac-str-notlike-*") }, false}, + {"StringNotLike allows nonmatching wildcard", "ac-str-abc-" + genRandString(6), + func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "zzz-*") }, true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition(tc.callerName)}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, tc.callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionStringMultipleExpectedValuesOR verifies a +// StringEquals condition with an array of expected values matches if the +// actual value equals *any* of them. +func IAMAccessControl_ConditionStringMultipleExpectedValuesOR(s *S3Conf) error { + testName := "IAMAccessControl_ConditionStringMultipleExpectedValuesOR" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-str-or-" + genRandString(8) + condition := cond("StringEquals", "aws:username", []string{"nobody-1", callerName, "nobody-2"}) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionArnOperators covers the ArnEquals/ArnLike/ +// ArnNotEquals/ArnNotLike family against aws:PrincipalArn — a real, +// fully-known ARN this suite controls exactly (the caller's own Arn). +func IAMAccessControl_ConditionArnOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionArnOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-arn-" + genRandString(8) + callerArnPattern := "arn:aws:iam::" + testAccountID + ":user/" + callerName + otherArn := "arn:aws:iam::" + testAccountID + ":user/someone-else" + + cases := []struct { + name string + condition json.RawMessage + wantAllowed bool + }{ + {"ArnEquals exact match", cond("ArnEquals", "aws:PrincipalArn", callerArnPattern), true}, + {"ArnEquals nonmatch", cond("ArnEquals", "aws:PrincipalArn", otherArn), false}, + {"ArnLike wildcard match", cond("ArnLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), true}, + {"ArnNotEquals matches when different", cond("ArnNotEquals", "aws:PrincipalArn", otherArn), true}, + {"ArnNotEquals denies when equal", cond("ArnNotEquals", "aws:PrincipalArn", callerArnPattern), false}, + {"ArnNotLike denies matching wildcard", cond("ArnNotLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), false}, + {"array of expected ARNs matches any", cond("ArnEquals", "aws:PrincipalArn", []string{otherArn, callerArnPattern}), true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionIpAddressRealSourceIp covers IpAddress/ +// NotIpAddress against the *real* aws:SourceIp the gateway observes for this +// test process's own connection (see callerSourceIP), proving the +// source-IP condition context is actually wired end to end — not just that +// the operator's CIDR logic works in isolation (see +// IAMAccessControl_ConditionIpAddressOperators for the broader operator +// coverage via a fully test-controlled claim value). +func IAMAccessControl_ConditionIpAddressRealSourceIp(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIpAddressRealSourceIp" + return iamActionHandler(s, testName, func(root *iam.Client) error { + sourceIP, err := callerSourceIP(s) + if err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + cases := []struct { + name string + condition json.RawMessage + wantAllowed bool + }{ + {"exact IP match", cond("IpAddress", "aws:SourceIp", sourceIP), true}, + {"broad CIDR match", cond("IpAddress", "aws:SourceIp", "127.0.0.0/8"), true}, + {"CIDR outside range denied", cond("IpAddress", "aws:SourceIp", "10.0.0.0/8"), false}, + {"NotIpAddress denies matching range", cond("NotIpAddress", "aws:SourceIp", "127.0.0.0/8"), false}, + {"NotIpAddress allows non-matching range", cond("NotIpAddress", "aws:SourceIp", "10.0.0.0/8"), true}, + {"multiple CIDRs, one matches (OR)", cond("IpAddress", "aws:SourceIp", []string{"10.0.0.0/8", "127.0.0.0/8"}), true}, + } + for _, tc := range cases { + if err := func() error { + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if tc.wantAllowed { + return wantAllowed(caller.arn, actGetUser, targetArn, err) + } + return wantDenied(caller.arn, actGetUser, targetArn, err) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow +// verifies a Deny scoped to one IP range carves it out of a broader Allow, +// using a range guaranteed to contain this test process's real source IP. +func IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + if _, err := callerSourceIP(s); err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("IpAddress", "aws:SourceIp", "127.0.0.0/8")}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionMultipleContextKeysANDed verifies two different +// condition keys within the same Condition block are ANDed: both +// aws:username and aws:PrincipalTag/department must match for the statement +// to apply. +func IAMAccessControl_ConditionMultipleContextKeysANDed(s *S3Conf) error { + testName := "IAMAccessControl_ConditionMultipleContextKeysANDed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-and-" + genRandString(8) + condition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": callerName, "aws:PrincipalTag/department": "eng"}, + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + + // Both keys match. + matching, cleanupMatching, err := newAccessControlCallerTagged(root, s, callerName, map[string]string{"p": policy}, map[string]string{"department": "eng"}) + if err != nil { + return err + } + defer cleanupMatching() + if _, err := getIAMUser(matching.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(matching.arn, actGetUser, targetArn, err) != nil { + return fmt.Errorf("both keys match: %w", wantAllowed(matching.arn, actGetUser, targetArn, err)) + } + + // Username matches but the tag does not: one failed key voids the + // whole statement (AND, not OR, across keys). + wrongTagName := "ac-and-" + genRandString(8) + wrongTagCondition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": wrongTagName, "aws:PrincipalTag/department": "eng"}, + }) + wrongTagPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: wrongTagCondition}) + mismatched, cleanupMismatched, err := newAccessControlCallerTagged(root, s, wrongTagName, map[string]string{"p": wrongTagPolicy}, map[string]string{"department": "sales"}) + if err != nil { + return err + } + defer cleanupMismatched() + _, err = getIAMUser(mismatched.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantDenied(mismatched.arn, actGetUser, targetArn, err); err != nil { + return fmt.Errorf("one key mismatched: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply verifies +// that when an Allow's condition matches but a separate Deny statement's own +// condition does *not* match, the Deny simply doesn't apply and the Allow +// wins — a failing condition on a Deny is not the same as the Deny being +// absent, but it does mean that particular Deny never fires. +func IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply(s *S3Conf) error { + testName := "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-mixed-" + genRandString(8) + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", "not-"+callerName)}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins verifies that when +// both an Allow's and a Deny's conditions match the same request, the Deny +// still wins — condition-matching does not change explicit Deny precedence. +func IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins(s *S3Conf) error { + testName := "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-bothmatch-" + genRandString(8) + policy := policyDoc( + accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)}, + accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)}, + ) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionOneFailedConditionVoidsStatement verifies a +// statement combining two condition keys (ANDed) does not apply if either +// one fails to match — demonstrated here via aws:username (matching) AND +// aws:SourceIp (deliberately scoped to a range that excludes this test +// process's real source IP). +func IAMAccessControl_ConditionOneFailedConditionVoidsStatement(s *S3Conf) error { + testName := "IAMAccessControl_ConditionOneFailedConditionVoidsStatement" + return iamActionHandler(s, testName, func(root *iam.Client) error { + if _, err := callerSourceIP(s); err != nil { + return err + } + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + callerName := "ac-voided-" + genRandString(8) + condition := condAll(map[string]map[string]any{ + "StringEquals": {"aws:username": callerName}, + "IpAddress": {"aws:SourceIp": "10.0.0.0/8"}, // deliberately excludes the real (127.0.0.0/8) source + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantDenied(caller.arn, actGetUser, targetArn, err) + }) +} + +// IAMAccessControl_ConditionNullPrincipalTag covers the Null operator +// against aws:PrincipalTag/, a key that's genuinely absent from +// request context for an untagged caller and present for a tagged one — +// exercising Null's "key does not exist"/"key exists" semantics against a +// real, request-driven context key rather than a synthetic one. +func IAMAccessControl_ConditionNullPrincipalTag(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNullPrincipalTag" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + run := func(name string, tags map[string]string, nullValue string, wantAllow bool) error { + condition := cond("Null", "aws:PrincipalTag/department", nullValue) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if wantAllow { + err = wantAllowed(caller.arn, actGetUser, targetArn, err) + } else { + err = wantDenied(caller.arn, actGetUser, targetArn, err) + } + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("Null true matches absent tag", nil, "true", true); err != nil { + return err + } + if err := run("Null true denies present tag", map[string]string{"department": "eng"}, "true", false); err != nil { + return err + } + if err := run("Null false matches present tag", map[string]string{"department": "eng"}, "false", true); err != nil { + return err + } + return run("Null false denies absent tag", nil, "false", false) + }) +} + +// IAMAccessControl_ConditionIfExistsPrincipalTag covers a StringEqualsIfExists +// condition against aws:PrincipalTag/: absent (vacuously allowed), +// present and matching (allowed), present and mismatched (denied). +func IAMAccessControl_ConditionIfExistsPrincipalTag(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIfExistsPrincipalTag" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + condition := cond("StringEqualsIfExists", "aws:PrincipalTag/department", "eng") + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + + run := func(name string, tags map[string]string, wantAllow bool) error { + caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if wantAllow { + err = wantAllowed(caller.arn, actGetUser, targetArn, err) + } else { + err = wantDenied(caller.arn, actGetUser, targetArn, err) + } + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil + } + + if err := run("absent tag is vacuously allowed", nil, true); err != nil { + return err + } + if err := run("present matching tag allowed", map[string]string{"department": "eng"}, true); err != nil { + return err + } + return run("present mismatched tag denied", map[string]string{"department": "sales"}, false) + }) +} + +// IAMAccessControl_ConditionResourceTagOnTarget covers iam:ResourceTag/ +// aws:ResourceTag: a Condition scoping the *target* resource's own tag, +// proving resourceForAction's tag resolution is wired into Condition +// evaluation, not just the caller's own tags. +func IAMAccessControl_ConditionResourceTagOnTarget(s *S3Conf) error { + testName := "IAMAccessControl_ConditionResourceTagOnTarget" + return iamActionHandler(s, testName, func(root *iam.Client) error { + taggedName, taggedArn, cleanupTagged, err := newTargetUserTagged(root, map[string]string{"team": "payments"}) + if err != nil { + return err + } + defer cleanupTagged() + untaggedName, untaggedArn, cleanupUntagged, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupUntagged() + + condition := cond("StringEquals", "iam:ResourceTag/team", "payments") + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*", Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(taggedName)}); wantAllowed(caller.arn, actGetUser, taggedArn, err) != nil { + return fmt.Errorf("matching resource tag: %w", wantAllowed(caller.arn, actGetUser, taggedArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(untaggedName)}) + if err := wantDenied(caller.arn, actGetUser, untaggedArn, err); err != nil { + return fmt.Errorf("untagged resource: %w", err) + } + return nil + }) +} + +// IAMAccessControl_ConditionRequestTagOnCreateUser covers aws:RequestTag/ +// aws:TagKeys: a Condition scoping the Tags parameter of a CreateUser +// request itself, proving request-scoped (not just principal- or +// resource-scoped) context is evaluated. +func IAMAccessControl_ConditionRequestTagOnCreateUser(s *S3Conf) error { + testName := "IAMAccessControl_ConditionRequestTagOnCreateUser" + return iamActionHandler(s, testName, func(root *iam.Client) error { + condition := cond("StringEquals", "aws:RequestTag/team", "payments") + policy := policyDoc(accessStatement{ + Effect: "Allow", Action: actCreateUser, + Resource: "arn:aws:iam::" + testAccountID + ":user/ac-created-*", + Condition: condition, + }) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + allowedName := "ac-created-" + genRandString(10) + out, err := createIAMUser(caller.client, &iam.CreateUserInput{ + UserName: aws.String(allowedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("payments")}}, + }) + if err := wantAllowed(caller.arn, actCreateUser, allowedName, err); err != nil { + return fmt.Errorf("matching request tag: %w", err) + } + if out != nil { + defer deleteIAMUser(root, allowedName) + } + + deniedName := "ac-created-" + genRandString(10) + _, err = createIAMUser(caller.client, &iam.CreateUserInput{ + UserName: aws.String(deniedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("other")}}, + }) + return wantDenied(caller.arn, actCreateUser, deniedName, err) + }) +} + +// IAMAccessControl_ConditionCurrentTimeBroadWindow covers Numeric/Date +// operators against the server's own request-time keys (aws:EpochTime, +// aws:CurrentTime) — since "now" can't be injected or fixed by the test, +// this uses deliberately broad, never-flaky bounds (year 2001 through year +// 2100) rather than tight boundaries; see +// IAMAccessControl_ConditionNumericOperators/ConditionDateOperators for +// precise boundary coverage against a fully test-controlled claim value. +func IAMAccessControl_ConditionCurrentTimeBroadWindow(s *S3Conf) error { + testName := "IAMAccessControl_ConditionCurrentTimeBroadWindow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + + condition := condAll(map[string]map[string]any{ + "NumericGreaterThan": {"aws:EpochTime": "1000000000"}, // ~2001 + "NumericLessThan": {"aws:EpochTime": "4102444800"}, // ~2100 + "DateGreaterThan": {"aws:CurrentTime": "2001-01-01T00:00:00Z"}, + "DateLessThan": {"aws:CurrentTime": "2100-01-01T00:00:00Z"}, + }) + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + return wantAllowed(caller.arn, actGetUser, targetArn, err) + }) +} + +// upperASCII uppercases a plain ASCII string (test fixture names are always +// ASCII), avoiding a dependency on strings.ToUpper's full-Unicode behavior +// for what's fundamentally a fixed test value. +func upperASCII(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - ('a' - 'A') + } + } + return string(b) +} + +// IAMAccessControl_ConditionNumericOperators covers the full Numeric +// condition-operator family, using a custom "level" claim this suite fully +// controls, around a fixed boundary value of 5. +func IAMAccessControl_ConditionNumericOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNumericOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + numCond := func(operator string, value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond(operator, host+":level", value) } + } + cases := []federatedConditionCase{ + {"NumericEquals at boundary allowed", map[string]any{"level": 5}, numCond("NumericEquals", 5), true}, + {"NumericEquals off boundary denied", map[string]any{"level": 5}, numCond("NumericEquals", 6), false}, + {"NumericNotEquals allowed when different", map[string]any{"level": 5}, numCond("NumericNotEquals", 6), true}, + {"NumericNotEquals denied when equal", map[string]any{"level": 5}, numCond("NumericNotEquals", 5), false}, + {"NumericLessThan below boundary allowed", map[string]any{"level": 5}, numCond("NumericLessThan", 6), true}, + {"NumericLessThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericLessThan", 5), false}, + {"NumericLessThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericLessThanEquals", 5), true}, + {"NumericLessThanEquals above boundary denied", map[string]any{"level": 6}, numCond("NumericLessThanEquals", 5), false}, + {"NumericGreaterThan above boundary allowed", map[string]any{"level": 6}, numCond("NumericGreaterThan", 5), true}, + {"NumericGreaterThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThan", 5), false}, + {"NumericGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThanEquals", 5), true}, + {"NumericGreaterThanEquals below boundary denied", map[string]any{"level": 4}, numCond("NumericGreaterThanEquals", 5), false}, + {"multiple expected values matches any (OR)", map[string]any{"level": 5}, numCond("NumericEquals", []any{5, 100}), true}, + {"missing context key denies", map[string]any{}, numCond("NumericEquals", 5), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionDateOperators covers the full Date +// condition-operator family, using a custom "joined" claim around a fixed +// boundary of 2024-06-15T00:00:00Z (epoch 1718409600) — both RFC3339 and +// epoch-seconds forms are exercised since evaluateCondition accepts either +// on either side. +func IAMAccessControl_ConditionDateOperators(s *S3Conf) error { + testName := "IAMAccessControl_ConditionDateOperators" + return iamActionHandler(s, testName, func(root *iam.Client) error { + const boundary = "2024-06-15T00:00:00Z" + const before = "2024-01-01T00:00:00Z" + const after = "2024-12-01T00:00:00Z" + dateCond := func(operator string, value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond(operator, host+":joined", value) } + } + cases := []federatedConditionCase{ + {"DateEquals exact match", map[string]any{"joined": boundary}, dateCond("DateEquals", boundary), true}, + {"DateEquals nonmatch", map[string]any{"joined": boundary}, dateCond("DateEquals", before), false}, + {"DateEquals matches across epoch-vs-RFC3339 forms", map[string]any{"joined": "1718409600"}, dateCond("DateEquals", boundary), true}, + {"DateNotEquals allowed when different", map[string]any{"joined": boundary}, dateCond("DateNotEquals", before), true}, + {"DateNotEquals denied when equal", map[string]any{"joined": boundary}, dateCond("DateNotEquals", boundary), false}, + {"DateLessThan before boundary allowed", map[string]any{"joined": before}, dateCond("DateLessThan", boundary), true}, + {"DateLessThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThan", boundary), false}, + {"DateLessThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThanEquals", boundary), true}, + {"DateLessThanEquals after boundary denied", map[string]any{"joined": after}, dateCond("DateLessThanEquals", boundary), false}, + {"DateGreaterThan after boundary allowed", map[string]any{"joined": after}, dateCond("DateGreaterThan", boundary), true}, + {"DateGreaterThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThan", boundary), false}, + {"DateGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThanEquals", boundary), true}, + {"DateGreaterThanEquals before boundary denied", map[string]any{"joined": before}, dateCond("DateGreaterThanEquals", boundary), false}, + {"multiple expected dates matches any (OR)", map[string]any{"joined": boundary}, dateCond("DateEquals", []any{before, boundary}), true}, + {"missing date context denies", map[string]any{}, dateCond("DateGreaterThan", boundary), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionBoolOperator covers Bool: true/false claim +// values, a string-typed "true"/"false" claim (still matched, since both +// sides parse via strconv.ParseBool), and a missing key. +func IAMAccessControl_ConditionBoolOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionBoolOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + boolCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("Bool", host+":admin", value) } + } + cases := []federatedConditionCase{ + {"true claim matches Bool true", map[string]any{"admin": true}, boolCond(true), true}, + {"false claim denied against Bool true", map[string]any{"admin": false}, boolCond(true), false}, + {"false claim matches Bool false", map[string]any{"admin": false}, boolCond(false), true}, + {"string representation \"true\" matches Bool true", map[string]any{"admin": "true"}, boolCond(true), true}, + {"missing key denies", map[string]any{}, boolCond(true), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionNullOperatorClaim covers Null against a custom +// claim: key exists vs. does not, Null:true vs. Null:false, and Null +// combined (ANDed) with a separate StringEquals condition in the same +// statement. +func IAMAccessControl_ConditionNullOperatorClaim(s *S3Conf) error { + testName := "IAMAccessControl_ConditionNullOperatorClaim" + return iamActionHandler(s, testName, func(root *iam.Client) error { + nullCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("Null", host+":nickname", value) } + } + cases := []federatedConditionCase{ + {"Null true matches when key absent", map[string]any{}, nullCond("true"), true}, + {"Null true denies when key present", map[string]any{"nickname": "bob"}, nullCond("true"), false}, + {"Null false matches when key present", map[string]any{"nickname": "bob"}, nullCond("false"), true}, + {"Null false denies when key absent", map[string]any{}, nullCond("false"), false}, + { + "Null combined with StringEquals: both satisfied allowed", + map[string]any{"nickname": "bob"}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "Null": {host + ":nickname": "false"}, + "StringEquals": {host + ":nickname": "bob"}, + }) + }, + true, + }, + { + "Null combined with StringEquals: Null satisfied but StringEquals fails denies", + map[string]any{"nickname": "bob"}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "Null": {host + ":nickname": "false"}, + "StringEquals": {host + ":nickname": "someone-else"}, + }) + }, + false, + }, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionBinaryEqualsOperator covers BinaryEquals with +// deterministic base64-encoded claim values. +func IAMAccessControl_ConditionBinaryEqualsOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionBinaryEqualsOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + const wantB64 = "aGVsbG8=" // base64("hello") + const otherB64 = "d29ybGQ=" // base64("world") + binCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("BinaryEquals", host+":cert", value) } + } + cases := []federatedConditionCase{ + {"matching base64 value allowed", map[string]any{"cert": wantB64}, binCond(wantB64), true}, + {"nonmatching base64 value denied", map[string]any{"cert": otherB64}, binCond(wantB64), false}, + {"missing key denied", map[string]any{}, binCond(wantB64), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionForAnyValueOperator covers ForAnyValue: +// StringEquals against a multi-valued "groups" claim: one request value +// matching is enough. +func IAMAccessControl_ConditionForAnyValueOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionForAnyValueOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + anyCond := func(expected any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("ForAnyValue:StringEquals", host+":groups", expected) } + } + cases := []federatedConditionCase{ + {"one request value matches", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"qa", "admin"}), true}, + {"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"dev", "qa"}), true}, + {"none match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"admin"}), false}, + {"empty request-value set never matches", map[string]any{"groups": []string{}}, anyCond([]any{"dev"}), false}, + {"missing context key denies", map[string]any{}, anyCond([]any{"dev"}), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionForAllValuesOperator covers +// ForAllValues:StringEquals against a multi-valued "groups" claim: every +// request value must match one of the expected values. +func IAMAccessControl_ConditionForAllValuesOperator(s *S3Conf) error { + testName := "IAMAccessControl_ConditionForAllValuesOperator" + return iamActionHandler(s, testName, func(root *iam.Client) error { + allCond := func(expected any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("ForAllValues:StringEquals", host+":groups", expected) } + } + cases := []federatedConditionCase{ + {"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev", "qa", "admin"}), true}, + {"only some request values match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev"}), false}, + {"none match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"admin"}), false}, + {"empty request-value set is vacuously true", map[string]any{"groups": []string{}}, allCond([]any{"dev"}), true}, + {"missing context key is vacuously true", map[string]any{}, allCond([]any{"dev"}), true}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionIfExistsTrustClaim covers a *IfExists operator +// against a custom claim: absent (vacuously allowed), present and matching +// (allowed), present and mismatched (denied). +func IAMAccessControl_ConditionIfExistsTrustClaim(s *S3Conf) error { + testName := "IAMAccessControl_ConditionIfExistsTrustClaim" + return iamActionHandler(s, testName, func(root *iam.Client) error { + ifExistsCond := func(value any) func(string) json.RawMessage { + return func(host string) json.RawMessage { return cond("StringEqualsIfExists", host+":department", value) } + } + cases := []federatedConditionCase{ + {"absent key is vacuously allowed", map[string]any{}, ifExistsCond("eng"), true}, + {"present matching key allowed", map[string]any{"department": "eng"}, ifExistsCond("eng"), true}, + {"present mismatched key denied", map[string]any{"department": "sales"}, ifExistsCond("eng"), false}, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust verifies two +// separate operator blocks in the same trust-statement Condition (a +// StringEquals on sub and a NumericGreaterThan on a custom claim) are +// ANDed: both must be satisfied. +func IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust(s *S3Conf) error { + testName := "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []federatedConditionCase{ + { + "both operator blocks satisfied allowed", + map[string]any{"sub": "user1", "level": 5}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "StringEquals": {host + ":sub": "user1"}, + "NumericGreaterThan": {host + ":level": 3}, + }) + }, + true, + }, + { + "sub matches but level condition fails denies", + map[string]any{"sub": "user1", "level": 2}, + func(host string) json.RawMessage { + return condAll(map[string]map[string]any{ + "StringEquals": {host + ":sub": "user1"}, + "NumericGreaterThan": {host + ":level": 3}, + }) + }, + false, + }, + } + return runFederatedConditionCases(root, s, cases) + }) +} + +// Principal-related authorization decisions are tested exclusively through +// role trust policies: an identity-based inline policy can never carry a +// Principal at all (PutUserPolicy/PutRolePolicy reject one outright), so +// there is nothing to test on that side. Within trust policies, only +// Principal.Federated is ever consulted at runtime — this gateway +// implements just sts:AssumeRoleWithWebIdentity, never a plain sts:AssumeRole +// or AssumeRoleWithSAML, so an "AWS" (IAM user/role/root/account) or +// "Service" principal, while accepted by write-time validation, has no +// runtime authorization meaning at all. IAMAccessControl_ +// TrustPolicyNonFederatedPrincipalsIgnored demonstrates this divergence from +// real AWS directly. NotPrincipal is likewise grammar-recognized but +// unconditionally rejected at write time on both identity and trust +// policies (Allow and Deny alike), so no valid stored policy can ever carry +// one — there is no authorization decision to test, only a validation +// rejection, which is out of this suite's scope by design. + +// IAMAccessControl_TrustPolicyFederatedExactMatchAllowed verifies a trust +// policy naming the exact registered OIDC provider ARN as its Federated +// principal allows assumption for a token issued by that provider. +func IAMAccessControl_TrustPolicyFederatedExactMatchAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyFederatedWrongProviderDenied verifies a trust +// policy federating a *real, registered* provider still denies a token +// issued by a *different* real, registered provider — an existing-but- +// mismatched principal, distinct from a dangling reference to a provider +// that was never created at all (see +// IAMAssumeRoleWithWebIdentity_no_matching_principal for that case). +func IAMAccessControl_TrustPolicyFederatedWrongProviderDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, _, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupRole() + + otherProviderURL := newLoopbackOIDCURL() + otherProviderArn, err := createTestOIDCProviderWithURL(root, otherProviderURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, otherProviderArn) + + token := mustToken(map[string]any{"iss": otherProviderURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyFederatedArrayMatchesAny verifies a Federated +// principal given as an array of provider ARNs matches a token issued by +// *either* one. +func IAMAccessControl_TrustPolicyFederatedArrayMatchesAny(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny" + return iamActionHandler(s, testName, func(root *iam.Client) error { + firstURL := newLoopbackOIDCURL() + firstArn, err := createTestOIDCProviderWithURL(root, firstURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, firstArn) + + roleArn, secondURL, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": []string{firstArn, providerArn}}, Action: "sts:AssumeRoleWithWebIdentity", + }) + }, nil) + if err != nil { + return err + } + defer cleanupRole() + + // A token from the *second* array entry (not the first) still matches. + token := mustToken(map[string]any{"iss": secondURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored documents a +// meaningful divergence from real AWS IAM: this gateway's only +// AssumeRole-family action is AssumeRoleWithWebIdentity, so +// EvaluateWebIdentityTrust only ever inspects a statement's +// Principal.Federated value — an "AWS" principal (even a wildcard "*", or a +// literal account root ARN, both of which would grant real AWS's plain +// sts:AssumeRole) or a "Service" principal is accepted by write-time +// validation but has no runtime effect: a role trusting *only* one of these +// can never actually be assumed by anyone, denied exactly as if the trust +// policy had no usable principal at all. +func IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + principal any + }{ + {"AWS wildcard principal alone", map[string]any{"AWS": "*"}}, + {"AWS root account principal alone", map[string]any{"AWS": "arn:aws:iam::" + testAccountID + ":root"}}, + {"Service principal alone", map[string]any{"Service": "sts.amazonaws.com"}}, + } + for _, tc := range cases { + if err := func() error { + roleName := "ac-nonfed-" + genRandString(12) + trust := trustDoc(trustStatement{Effect: "Allow", Principal: tc.principal, Action: "sts:AssumeRoleWithWebIdentity"}) + if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil { + return err + } + defer deleteIAMRole(root, roleName) + + roleArn := "arn:aws:iam::" + testAccountID + ":role/" + roleName + token := mustToken(map[string]any{"iss": "https://unused.example.com", "aud": "client1", "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedNoPrincipal(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed verifies a +// StringEquals condition on :sub allows a token whose subject +// matches exactly. +func IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/main"}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied is the +// StringEqualsSubjectExactAllowed companion: a different repository's +// subject is denied. +func IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/other-repo:ref:refs/heads/main"}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed verifies a +// StringLike condition on :sub with a trailing wildcard allows any +// branch under refs/heads/ — a realistic GitHub-Actions-style pattern. +func IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/feature-x"}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied is the +// StringLikeBranchWildcardAllowed companion: a pull-request-triggered +// subject (a different sub shape entirely, not matching the refs/heads/* +// pattern) is denied. +func IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:pull_request"}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceCorrectAllowed verifies a StringEquals +// condition on :aud allows a token whose (ClientIDList-valid) +// audience matches the condition's expected value. The provider's +// ClientIDList registers *two* acceptable audiences so this and +// AudienceIncorrectDenied can each present a ClientIDList-valid audience, +// isolating the Condition itself as what's actually under test (see +// newFederatedRole's doc comment). +func IAMAccessControl_TrustPolicyAudienceCorrectAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceCorrectAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", "expected-aud"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "expected-aud", "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceIncorrectDenied is the +// AudienceCorrectAllowed companion: an audience that's valid per +// ClientIDList but doesn't match the trust policy's Condition is denied. +func IAMAccessControl_TrustPolicyAudienceIncorrectDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceIncorrectDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", "expected-aud"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "other-aud", "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed verifies a +// StringEquals condition on :aud with an array of acceptable +// values matches any one of them. +func IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"aud-one", "aud-two"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":aud", []string{"aud-one", "aud-two"}), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": "aud-two", "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch verifies a +// trust statement with Conditions on both :aud and :sub +// requires both to match — either alone is not enough. +func IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + aud, sub string + wantAllowed bool + }{ + {"both match allowed", "expected-aud", "expected-sub", true}, + {"only audience matches denied", "expected-aud", "wrong-sub", false}, + {"only subject matches denied", "wrong-aud", "expected-sub", false}, + {"neither matches denied", "wrong-aud", "wrong-sub", false}, + } + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "wrong-aud"}, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: condAll(map[string]map[string]any{ + "StringEquals": {host + ":aud": "expected-aud", host + ":sub": "expected-sub"}, + }), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": tc.aud, "sub": tc.sub, "exp": 9999999999}) + if tc.wantAllowed { + return wantTrustAllowed(s, roleArn, token) + } + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyExplicitDenyStatement verifies an explicit +// Deny statement scoped to one subject blocks assumption for that subject +// while a broader Allow still covers every other subject. +func IAMAccessControl_TrustPolicyExplicitDenyStatement(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyExplicitDenyStatement" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc( + trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + trustStatement{ + Effect: "Deny", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "blocked-user"), + }, + ) + }, nil) + if err != nil { + return err + } + defer cleanup() + + blockedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "blocked-user", "exp": 9999999999}) + if err := wantTrustDeniedExplicit(s, roleArn, blockedToken); err != nil { + return fmt.Errorf("blocked subject: %w", err) + } + + allowedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999}) + if err := wantTrustAllowed(s, roleArn, allowedToken); err != nil { + return fmt.Errorf("non-blocked subject: %w", err) + } + return nil + }) +} + +// IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants verifies a +// trust policy is evaluated statement by statement across the whole +// document: a first statement referencing an unrelated provider doesn't +// prevent a second statement (for the *actual* issuer) from granting +// assumption. +func IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants" + return iamActionHandler(s, testName, func(root *iam.Client) error { + unrelatedURL := newLoopbackOIDCURL() + unrelatedArn, err := createTestOIDCProviderWithURL(root, unrelatedURL) + if err != nil { + return err + } + defer deleteOIDCProvider(root, unrelatedArn) + + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc( + trustStatement{Sid: "Unrelated", Effect: "Allow", Principal: map[string]any{"Federated": unrelatedArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + trustStatement{Sid: "Actual", Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}, + ) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }) +} + +// IAMAccessControl_TrustPolicyMissingRequiredClaimDenied verifies a +// StringEquals condition against a claim key the token simply never carries +// denies assumption — a positive (non-IfExists) operator against an absent +// key fails closed (see IAMAccessControl_ConditionIfExistsTrustClaim for +// the IfExists variant's opposite behavior on the same kind of absence). +func IAMAccessControl_TrustPolicyMissingRequiredClaimDenied(s *S3Conf) error { + testName := "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":employee_id", "12345"), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + // The token never includes an employee_id claim at all. + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_UserInlinePolicyWorkflow exercises the full lifecycle a +// user's inline policy goes through: create two users (one caller, one +// target), attach an inline policy scoped to a condition on the caller's +// own identity, create access keys, make signed calls as the caller, +// verify the permitted action+resource succeeds, verify denial for another +// action, another user resource, a condition mismatch (a second, +// differently-named caller under the same policy shape), and an explicit +// Deny, then update the policy and verify the changed authorization takes +// effect while the explicit Deny still holds. +func IAMAccessControl_UserInlinePolicyWorkflow(s *S3Conf) error { + testName := "IAMAccessControl_UserInlinePolicyWorkflow" + return iamActionHandler(s, testName, func(root *iam.Client) error { + targetName, targetArn, cleanupTarget, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupTarget() + otherName, otherArn, cleanupOther, err := newTargetUser(root) + if err != nil { + return err + } + defer cleanupOther() + + callerName := "ac-workflow-" + genRandString(10) + grant := func(callerUserName string) string { + return policyDoc( + accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: actGetUser, Resource: targetArn, + Condition: cond("StringEquals", "aws:username", callerUserName)}, + accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"}, + ) + } + caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"grant": grant(callerName)}) + if err != nil { + return err + } + defer cleanupCaller() + + // Permitted action + resource succeeds, and genuinely returns the + // target's data (not just a nil error). + getOut, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}) + if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil { + return fmt.Errorf("permitted action+resource: %w", err) + } + if getOut == nil || getOut.User == nil || aws.ToString(getOut.User.UserName) != targetName { + return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, getOut) + } + + // Another action against the same resource is denied. + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantDenied(caller.arn, actListUserPolicies, targetArn, err) != nil { + return fmt.Errorf("another action: %w", wantDenied(caller.arn, actListUserPolicies, targetArn, err)) + } + + // The same permitted action against a different user resource is denied. + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, otherArn, err) != nil { + return fmt.Errorf("another resource: %w", wantDenied(caller.arn, actGetUser, otherArn, err)) + } + + // A condition mismatch (a caller whose own username differs from what + // the policy's Condition expects) is denied even under the identical + // policy shape. + mismatchName := "ac-workflow-" + genRandString(10) + mismatchCaller, cleanupMismatch, err := newAccessControlCaller(root, s, mismatchName, map[string]string{"grant": grant(callerName)}) + if err != nil { + return err + } + defer cleanupMismatch() + if _, err := getIAMUser(mismatchCaller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantDenied(mismatchCaller.arn, actGetUser, targetArn, err) != nil { + return fmt.Errorf("condition mismatch: %w", wantDenied(mismatchCaller.arn, actGetUser, targetArn, err)) + } + + // An explicit Deny blocks an action the broad wildcard Resource on + // that statement would otherwise apply to, regardless of what the + // named policy/resource actually is. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil { + return fmt.Errorf("explicit deny: %w", err) + } + + // Updating the policy to grant the previously-denied action takes + // effect immediately. + updated := policyDoc( + accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: []string{actGetUser, actListUserPolicies}, Resource: targetArn, + Condition: cond("StringEquals", "aws:username", callerName)}, + accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"}, + ) + if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(caller.userName), PolicyName: aws.String("grant"), PolicyDocument: aws.String(updated), + }); err != nil { + return fmt.Errorf("update policy: %w", err) + } + if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil { + return fmt.Errorf("newly granted action after update: %w", wantAllowed(caller.arn, actListUserPolicies, targetArn, err)) + } + + // The explicit Deny is still in effect after the update. + _, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")}) + if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil { + return fmt.Errorf("explicit deny after update: %w", err) + } + return nil + }) +} + +// IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath verifies a +// resource pattern scoped to one path prefix grants access to users under +// that path but not to a user with a different path, even with an +// otherwise-identical name prefix. +func IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath(s *S3Conf) error { + testName := "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath" + return iamActionHandler(s, testName, func(root *iam.Client) error { + inPathName, inPathArn, cleanupInPath, err := newTargetUserWithPath(root, "/ac-finance/") + if err != nil { + return err + } + defer cleanupInPath() + outOfPathName, outOfPathArn, cleanupOutOfPath, err := newTargetUserWithPath(root, "/ac-marketing/") + if err != nil { + return err + } + defer cleanupOutOfPath() + + policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/ac-finance/*"}) + caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy}) + if err != nil { + return err + } + defer cleanupCaller() + + if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(inPathName)}); wantAllowed(caller.arn, actGetUser, inPathArn, err) != nil { + return fmt.Errorf("in-path user: %w", wantAllowed(caller.arn, actGetUser, inPathArn, err)) + } + _, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(outOfPathName)}) + if err := wantDenied(caller.arn, actGetUser, outOfPathArn, err); err != nil { + return fmt.Errorf("out-of-path user: %w", err) + } + return nil + }) +} + +// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision +// demonstrates that trust-policy authorization and role-permission +// authorization are separate stages: a role's inline (permission) policy — +// absent, permissive, or deny-all — has no bearing on whether the role can +// be assumed. Every variant reaches the identical trust-evaluation outcome +// (this suite's network-stage proxy for "Allowed", per the file doc +// comment) with the trust policy held fixed. +func IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision(s *S3Conf) error { + testName := "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision" + return iamActionHandler(s, testName, func(root *iam.Client) error { + cases := []struct { + name string + rolePermission map[string]string + }{ + {"no permission policy at all", nil}, + {"broad permissive permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})}}, + {"deny-all permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Deny", Action: "iam:*", Resource: "*"})}}, + } + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, tc.rolePermission) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + return wantTrustAllowed(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + +// IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy is the +// converse of RolePermissionPolicyDoesNotAffectAssumptionDecision: even a +// maximally permissive role permission policy cannot compensate for a trust +// policy that doesn't authorize the caller — assumption is still denied. +func IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy(s *S3Conf) error { + testName := "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, _, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + host := trimProviderScheme(providerURL) + return trustDoc(trustStatement{ + Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity", + Condition: cond("StringEquals", host+":sub", "expected-user"), + }) + }, map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})}) + if err != nil { + return err + } + defer cleanup() + + // A different subject: trust Condition fails despite the role's own + // permission policy granting everything. + token := mustToken(map[string]any{"iss": "https://unused-in-this-assertion.example.com", "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999}) + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }) +} + +// IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer +// verifies isolation between two independently-configured federated roles: +// a token issued for role A's provider cannot assume role B, even though it +// can (still) assume role A. +func IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer(s *S3Conf) error { + testName := "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleAArn, providerAURL, cleanupA, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupA() + + roleBArn, _, cleanupB, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanupB() + + tokenForA := mustToken(map[string]any{"iss": providerAURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + + if err := wantTrustAllowed(s, roleAArn, tokenForA); err != nil { + return fmt.Errorf("token still assumes its own role: %w", err) + } + if err := wantTrustDeniedInvalidClaims(s, roleBArn, tokenForA); err != nil { + return fmt.Errorf("same token cannot assume an unrelated role: %w", err) + } + return nil + }) +} + +// IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck +// documents a meaningful divergence from real AWS's plain sts:AssumeRole: +// this gateway's only assume-role action is unauthenticated (see +// stsOpenRoute in iamapi/router.go — VerifyIAMAuth never runs for it), so +// there is no calling IAM identity and thus no identity-based-policy check +// on the assumption call itself, only the target role's trust policy. This +// is demonstrated by showing an identical trust/token pair produces an +// identical result (the same network-dependent failure this suite uses +// throughout as its proxy for reaching a genuine Allowed decision — see the +// file doc comment) whether the request is signed with the real root +// credential or with a completely arbitrary, nonexistent access key: if +// caller identity mattered here, at least one of these would fail +// differently (e.g. an unknown-access-key error) instead of both reaching +// the identical outcome. +func IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck(s *S3Conf) error { + testName := "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck" + return iamActionHandler(s, testName, func(root *iam.Client) error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string { + return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"}) + }, nil) + if err != nil { + return err + } + defer cleanup() + + token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}) + + if err := wantTrustAllowed(s, roleArn, token); err != nil { + return fmt.Errorf("signed with the real root credential: %w", err) + } + + bogusCfg := *s + bogusCfg.awsID, bogusCfg.awsSecret = "AKIA"+genRandString(16), genRandString(32) + if err := wantTrustAllowed(&bogusCfg, roleArn, token); err != nil { + return fmt.Errorf("signed with an arbitrary, nonexistent access key: %w", err) + } + return nil + }) +} + +// accessControlCaller is an isolated IAM user with its own long-term access +// key, used as the authenticated caller for an identity-policy authorization +// test. +type accessControlCaller struct { + userName string + userID string + arn string + client *iam.Client +} + +// newAccessControlCaller creates an isolated IAM user (userName, or an +// auto-generated one if empty), attaches the given named inline policies +// (policyName -> document; may be nil/empty), creates one long-term access +// key, and returns an *iam.Client authenticated as that user plus a cleanup +// func that removes the key, every attached policy, and the user itself. +func newAccessControlCaller(root *iam.Client, s *S3Conf, userName string, policies map[string]string) (*accessControlCaller, func(), error) { + return newAccessControlCallerTagged(root, s, userName, policies, nil) +} + +// newAccessControlCallerTagged is newAccessControlCaller plus tags on the +// created user, for aws:PrincipalTag/Null/IfExists-style tests. +func newAccessControlCallerTagged(root *iam.Client, s *S3Conf, userName string, policies map[string]string, tags map[string]string) (*accessControlCaller, func(), error) { + if userName == "" { + userName = newIAMUserName() + } + + input := &iam.CreateUserInput{UserName: aws.String(userName)} + for k, v := range tags { + input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) + } + createOut, err := createIAMUser(root, input) + if err != nil { + return nil, nil, fmt.Errorf("create caller user: %w", err) + } + + for name, doc := range policies { + if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{ + UserName: aws.String(userName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + deleteIAMUser(root, userName) + return nil, nil, fmt.Errorf("attach caller policy %q: %w", name, err) + } + } + + keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + if err != nil { + deleteAccessControlCaller(root, userName) + return nil, nil, fmt.Errorf("create caller access key: %w", err) + } + + caller := &accessControlCaller{ + userName: userName, + userID: aws.ToString(createOut.User.UserId), + arn: aws.ToString(createOut.User.Arn), + client: iamClientWithCreds(s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey), ""), + } + cleanup := func() { deleteAccessControlCaller(root, userName) } + return caller, cleanup, nil +} + +// deleteAccessControlCaller removes every dependency DeleteUser would +// otherwise reject (inline policies, access keys) before deleting the user +// itself. Neither of the existing deleteIAMUserAndPolicies/ +// deleteIAMUserAndAccessKeys helpers alone covers the combination +// newAccessControlCaller's fixtures always create (both policies and a +// key), so this file needs its own. +func deleteAccessControlCaller(root *iam.Client, userName string) error { + polOut, err := listIAMUserPolicies(root, &iam.ListUserPoliciesInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, name := range polOut.PolicyNames { + if err := deleteIAMUserPolicy(root, userName, name); err != nil { + return err + } + } + + keyOut, err := listIAMAccessKeys(root, &iam.ListAccessKeysInput{UserName: aws.String(userName)}) + if err != nil { + return err + } + for _, key := range keyOut.AccessKeyMetadata { + if err := deleteIAMAccessKey(root, userName, aws.ToString(key.AccessKeyId)); err != nil { + return err + } + } + + return deleteIAMUser(root, userName) +} + +// newTargetUser creates a plain, isolated IAM user with no policies of its +// own, to be used as the resource another caller's policy is tested +// against. +func newTargetUser(root *iam.Client) (userName, arn string, cleanup func(), err error) { + return newTargetUserWithPath(root, "") +} + +// newTargetUserWithPath is newTargetUser with an explicit Path, for +// resource-path-wildcard tests. +func newTargetUserWithPath(root *iam.Client, path string) (userName, arn string, cleanup func(), err error) { + userName = "ac-target-" + genRandString(12) + input := &iam.CreateUserInput{UserName: aws.String(userName)} + if path != "" { + input.Path = aws.String(path) + } + out, err := createIAMUser(root, input) + if err != nil { + return "", "", nil, err + } + return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil +} + +// newTargetUserTagged is newTargetUser plus tags, for +// iam:ResourceTag/aws:ResourceTag condition tests. +func newTargetUserTagged(root *iam.Client, tags map[string]string) (userName, arn string, cleanup func(), err error) { + userName = "ac-target-" + genRandString(12) + input := &iam.CreateUserInput{UserName: aws.String(userName)} + for k, v := range tags { + input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) + } + out, err := createIAMUser(root, input) + if err != nil { + return "", "", nil, err + } + return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil +} + +// newTargetRole creates a plain role (permissive default trust policy, no +// inline policies) to be used as the resource another caller's policy is +// tested against. +func newTargetRole(root *iam.Client) (roleName, arn string, cleanup func(), err error) { + roleName = "ac-target-role-" + genRandString(12) + if _, err = createIAMRole(root, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return "", "", nil, err + } + return roleName, "arn:aws:iam::" + testAccountID + ":role/" + roleName, func() { deleteIAMRole(root, roleName) }, nil +} + +// iamClientWithCreds builds an *iam.Client authenticated as the given +// access/secret/session-token triple, reusing s's endpoint/region/http +// client. S3Conf has no session-token field of its own (only +// AssumeRoleWithWebIdentity-derived credentials would ever need one, and +// this file never gets that far — see the file doc comment), so every call +// site here passes token="" — but the parameter exists so this stays +// reusable if that ever changes. +func iamClientWithCreds(s *S3Conf, access, secret, token string) *iam.Client { + cfg := s.Config() + cfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + return iam.NewFromConfig(cfg) +} + +// getIAMUser is the GetUser counterpart to the existing getIAMRole/ +// getIAMUserPolicy/getIAMRolePolicy helpers elsewhere in this package — no +// prior test file needed a generic wrapper for it. +func getIAMUser(client *iam.Client, input *iam.GetUserInput) (*iam.GetUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetUser(ctx, input) +} + +// wantAllowed reports a descriptive error if err is non-nil, identifying the +// caller, action, and resource a test expected to be authorized. +func wantAllowed(callerArn, action, resource string, err error) error { + if err != nil { + return fmt.Errorf("caller=%s action=%s resource=%s: expected ALLOW, got error: %v", callerArn, action, resource, err) + } + return nil +} + +// wantDenied asserts err is exactly the AccessDenied error VerifyIAMPolicy +// produces for callerArn/action — not merely "some error" (a wrong ARN, a +// missing parameter, or a not-found resource must not be mistaken for an +// authorization denial). +func wantDenied(callerArn, action, resource string, err error) error { + if cerr := checkIAMApiErr(err, iamerr.AccessDeniedIAMAction(callerArn, action)); cerr != nil { + return fmt.Errorf("caller=%s action=%s resource=%s: expected DENY: %w", callerArn, action, resource, cerr) + } + return nil +} + +// accessStatement is a safe, type-checked builder for one identity-policy +// statement — used instead of hand-formatted JSON strings so a test typo +// produces a Go compile error or a visibly-wrong marshaled document instead +// of a silently-malformed policy. Action/NotAction/Resource/NotResource +// accept either a bare string or a []string (both marshal the way this +// gateway's StringOrSlice unmarshals them). +type accessStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Action any `json:"Action,omitempty"` + NotAction any `json:"NotAction,omitempty"` + Resource any `json:"Resource,omitempty"` + NotResource any `json:"NotResource,omitempty"` + Condition json.RawMessage `json:"Condition,omitempty"` +} + +// policyDoc marshals statements into a complete "2012-10-17" identity-policy +// document string. Marshaling a fixed struct of strings/[]string/ +// json.RawMessage cannot fail in practice; a panic here means a test itself +// is malformed, not a runtime condition to recover from. +func policyDoc(statements ...accessStatement) string { + doc := struct { + Version string `json:"Version"` + Statement []accessStatement `json:"Statement"` + }{"2012-10-17", statements} + b, err := json.Marshal(doc) + if err != nil { + panic(fmt.Sprintf("iam_access_control: policyDoc: %v", err)) + } + return string(b) +} + +// trustStatement is accessStatement's counterpart for role trust policies: +// Principal is required (never NotPrincipal — see the file's Principal +// section for why versitygw rejects NotPrincipal unconditionally), and +// Resource/NotResource don't exist in trust-policy grammar at all. +type trustStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Principal any `json:"Principal"` + Action any `json:"Action,omitempty"` + NotAction any `json:"NotAction,omitempty"` + Condition json.RawMessage `json:"Condition,omitempty"` +} + +func trustDoc(statements ...trustStatement) string { + doc := struct { + Version string `json:"Version"` + Statement []trustStatement `json:"Statement"` + }{"2012-10-17", statements} + b, err := json.Marshal(doc) + if err != nil { + panic(fmt.Sprintf("iam_access_control: trustDoc: %v", err)) + } + return string(b) +} + +// cond builds a Condition block containing a single operator/key/value(s) +// entry, e.g. cond("StringEquals", "aws:username", "alice") or +// cond("StringEquals", "aws:username", []string{"alice", "bob"}). +func cond(operator, key string, value any) json.RawMessage { + b, err := json.Marshal(map[string]map[string]any{operator: {key: value}}) + if err != nil { + panic(fmt.Sprintf("iam_access_control: cond: %v", err)) + } + return b +} + +// condAll builds a Condition block from multiple operator blocks and/or +// multiple keys within a block, for multi-condition-semantics tests (see +// evaluateCondition's AND-across-operators/keys, OR-across-values +// semantics). +func condAll(blocks map[string]map[string]any) json.RawMessage { + b, err := json.Marshal(blocks) + if err != nil { + panic(fmt.Sprintf("iam_access_control: condAll: %v", err)) + } + return b +} + +// mustToken wraps webIdentityTokenWithClaims for call sites that pass fixed, +// well-formed claims — a marshal failure there means a test itself is +// malformed, not a runtime condition. +func mustToken(claims map[string]any) string { + tok, err := webIdentityTokenWithClaims(claims) + if err != nil { + panic(fmt.Sprintf("iam_access_control: mustToken: %v", err)) + } + return tok +} + +// newLoopbackOIDCURL returns a random loopback-IP-based OIDC provider URL. +// Every trust-policy test in this file that needs to observe an "Allowed" +// decision (see the file doc comment) federates a loopback provider so +// evaluation deterministically fails at the network-dependent signature step +// instead of hanging or attempting real internet access. A random address, +// rather than a fixed one like 127.0.0.1, keeps concurrently-running +// subtests from colliding on the same provider identity. +func newLoopbackOIDCURL() string { + return fmt.Sprintf("https://127.%d.%d.%d", 1+rand.Intn(254), 1+rand.Intn(254), 1+rand.Intn(254)) +} + +// newFederatedRole creates a fresh OIDC provider at a random loopback URL +// (see newLoopbackOIDCURL) with the given ClientIDList, then a role whose +// trust policy is buildTrust(providerArn, providerURL) — buildTrust is +// handed both so it can reference the provider as a Federated principal and +// build ":"-style Condition keys (via trimProviderScheme). +// rolePolicies (may be nil) are attached as the role's inline *permission* +// policies; several tests in this file deliberately vary these (empty, +// permissive, deny-all) while holding the trust policy fixed, to +// demonstrate that a role's permission policy has no bearing on whether it +// can be assumed — only its trust policy does (see +// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision). +func newFederatedRole(root *iam.Client, clientIDs []string, buildTrust func(providerArn, providerURL string) string, rolePolicies map[string]string) (roleArn, providerURL string, cleanup func(), err error) { + providerURL = newLoopbackOIDCURL() + out, err := createOIDCProvider(root, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", "", nil, fmt.Errorf("create provider: %w", err) + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + roleName := "ac-role-" + genRandString(12) + trust := buildTrust(providerArn, providerURL) + if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil { + deleteOIDCProvider(root, providerArn) + return "", "", nil, fmt.Errorf("create role: %w", err) + } + + for name, doc := range rolePolicies { + if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{ + RoleName: aws.String(roleName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc), + }); err != nil { + deleteIAMRoleAndPolicies(root, roleName) + deleteOIDCProvider(root, providerArn) + return "", "", nil, fmt.Errorf("attach role policy %q: %w", name, err) + } + } + + roleArn = "arn:aws:iam::" + testAccountID + ":role/" + roleName + cleanup = func() { + deleteIAMRoleAndPolicies(root, roleName) + deleteOIDCProvider(root, providerArn) + } + return roleArn, providerURL, cleanup, nil +} + +// wantTrustAllowed asserts that assuming roleArn with token reaches the +// network-dependent signature-verification stage — this suite's +// deterministic, black-box-observable proxy for "trust policy evaluation +// returned Allowed" (see the file doc comment). roleArn's trust policy must +// federate a loopback-URL provider (see newLoopbackOIDCURL/newFederatedRole) +// for the network step to fail deterministically instead of hanging or +// attempting real internet access. +func wantTrustAllowed(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.InvalidIdentityTokenIDPCommunicationError()) +} + +// wantTrustDeniedNoPrincipal asserts assumption fails the way it does when +// no statement's Federated principal resolves to a provider that actually +// exists (policy.NoPrincipal) — the same AccessDenied outcome AWS also uses +// for a role that doesn't exist at all, never confirming or denying which. +func wantTrustDeniedNoPrincipal(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) +} + +// wantTrustDeniedExplicit asserts assumption fails via an explicit Deny +// statement (policy.ExplicitlyDenied) — also AccessDenied, but reached via a +// different evaluation path than wantTrustDeniedNoPrincipal (a real, +// existing, issuer-matching provider whose statement actively denies, not an +// unresolvable principal). +func wantTrustDeniedExplicit(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) +} + +// wantTrustDeniedInvalidClaims asserts assumption fails at the claims stage +// (policy.NoIssuerMatch or policy.ConditionFailed) — an existing, correctly +// Federated provider whose Condition (or, elsewhere in this package, +// audience/issuer) didn't satisfy the request. +func wantTrustDeniedInvalidClaims(s *S3Conf, roleArn, token string) error { + _, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0) + return checkIAMApiErr(err, iamerr.InvalidIdentityTokenClaims()) +} + +// federatedConditionCase is one row of a table-driven trust-policy Condition +// test: a JWT claim (merged over the base iss/aud/sub/exp claims +// runFederatedConditionCases always supplies) paired with the Condition +// block a role's trust policy scopes, and whether that combination should +// let evaluation reach the network stage (wantTrustAllowed's proxy for +// "Allowed") or fail with InvalidIdentityTokenClaims. +type federatedConditionCase struct { + name string + claims map[string]any + condition func(host string) json.RawMessage + wantAllowed bool +} + +// runFederatedConditionCases runs each case against its own fresh +// provider/role (see newFederatedRole), always using defaultTestAudience so +// a case's outcome is driven solely by its own condition/claim, never an +// incidental audience mismatch. +func runFederatedConditionCases(root *iam.Client, s *S3Conf, cases []federatedConditionCase) error { + for _, tc := range cases { + if err := func() error { + roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string { + return trustDoc(trustStatement{ + Effect: "Allow", + Principal: map[string]any{"Federated": providerArn}, + Action: "sts:AssumeRoleWithWebIdentity", + Condition: tc.condition(trimProviderScheme(providerURL)), + }) + }, nil) + if err != nil { + return err + } + defer cleanup() + + claims := map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999} + for k, v := range tc.claims { + claims[k] = v + } + token := mustToken(claims) + + if tc.wantAllowed { + return wantTrustAllowed(s, roleArn, token) + } + return wantTrustDeniedInvalidClaims(s, roleArn, token) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil +} + +// callerSourceIP returns the IP address the gateway will observe as +// aws:SourceIp for requests made through s's configured endpoint — derived +// from the endpoint's own host rather than assumed, since loopback +// connections use the destination address as their source (no NAT), and the +// integration harness always points s's endpoint at a literal loopback IP +// (see runiamtests.sh). Returns an error rather than guessing if the +// endpoint's host isn't a literal IP, so an IP-condition test fails loudly +// instead of silently asserting against the wrong address. +func callerSourceIP(s *S3Conf) (string, error) { + u, err := url.Parse(s.endpoint) + if err != nil { + return "", fmt.Errorf("parse endpoint %q: %w", s.endpoint, err) + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("endpoint %q has no host", s.endpoint) + } + return host, nil +} diff --git a/tests/integration/iam_assume_role_with_web_identity.go b/tests/integration/iam_assume_role_with_web_identity.go new file mode 100644 index 00000000..84622aea --- /dev/null +++ b/tests/integration/iam_assume_role_with_web_identity.go @@ -0,0 +1,687 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/versity/versitygw/iamapi/iamerr" +) + +// stsUnauthConfig builds an authConfig for AssumeRoleWithWebIdentity, the +// one action in this whole gateway that requires no credentials at all: it +// still gets signed (as root, for convenience — reusing authHandler's +// request-building/runF/failF/passF plumbing) but the signature is never +// even checked server-side, so every request-validation test below reaches +// the server's own validation exactly as an entirely unsigned client would. +func stsUnauthConfig(testName string, params url.Values) *authConfig { + if !params.Has("Version") { + params.Set("Version", "2011-06-15") + } + return &authConfig{ + testName: testName, + method: http.MethodPost, + service: "sts", + region: iamAuthRegion, + body: []byte(params.Encode()), + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + } +} + +// checkSTSApiErr checks resp against expected, the way requireSTSError does +// in the iamapi package's own controller-level tests: STS errors render +// under a different XML namespace than IAM's (STSNamespace, or +// AWSFaultNamespace for InvalidAction specifically), so this can't reuse +// checkHTTPResponseIAMErr, which hard-codes iamerr.Namespace. +func checkSTSApiErr(resp *http.Response, expected iamerr.Error) error { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != expected.HTTPStatusCode { + return fmt.Errorf("expected response status code to be %v, instead got %v: %s", expected.HTTPStatusCode, resp.StatusCode, body) + } + + var errResp IAMErrorResponse + if err := xml.Unmarshal(body, &errResp); err != nil { + return fmt.Errorf("unmarshal STS error response: %w: %s", err, body) + } + + wantNamespace := iamerr.STSNamespace + if expected.Code == "InvalidAction" { + wantNamespace = iamerr.AWSFaultNamespace + } + if errResp.XMLName.Space != wantNamespace { + return fmt.Errorf("expected STS error namespace %q, instead got %q", wantNamespace, errResp.XMLName.Space) + } + if errResp.Error.Type != string(expected.Type) || errResp.Error.Code != expected.Code || errResp.Error.Message != expected.Message { + return fmt.Errorf("expected error type=%q code=%q message=%q, instead got type=%q code=%q message=%q", + expected.Type, expected.Code, expected.Message, errResp.Error.Type, errResp.Error.Code, errResp.Error.Message) + } + if errResp.RequestID == "" { + return fmt.Errorf("expected STS error response request id") + } + return nil +} + +// webIdentityTokenWithClaims builds an unverified (but structurally valid) +// JWT carrying claims. Sufficient for every trust-evaluation test below, +// none of which ever reach real signature verification (a trust-policy +// mismatch, audience mismatch, or condition failure is always detected +// first) — the sole exception, the IDP communication error test, needs +// exactly this and no more: real signature verification never succeeds +// against a fake identity provider regardless of what the token contains. +func webIdentityTokenWithClaims(claims map[string]any) (string, error) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".c2lnbmF0dXJl", nil +} + +// validWebIdentityToken is a structurally valid (but unverifiable — no +// registered provider will ever match its issuer) JWT carrying every claim +// AWS requires (including iat — its absence would itself be a rejection +// reason, see VerifyWebIdentityRequiredClaims), sufficient for exercising +// every AssumeRoleWithWebIdentity validation step that runs before a role is +// even looked up. +const validWebIdentityToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJpc3MiOiJodHRwczovL3VucmVnaXN0ZXJlZC5leGFtcGxlLmNvbSIsImF1ZCI6ImNsaWVudDEiLCJzdWIiOiJ1c2VyMSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ." + + "c2lnbmF0dXJl" + +func IAMAssumeRoleWithWebIdentity_missing_role_arn(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_missing_role_arn" + cfg := stsUnauthConfig(testName, url.Values{"Action": {"AssumeRoleWithWebIdentity"}}) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MissingValue("roleArn")) + }) +} + +func IAMAssumeRoleWithWebIdentity_role_arn_too_short(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_role_arn_too_short" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"short"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.ValueTooShort("roleArn", 20)) + }) +} + +func IAMAssumeRoleWithWebIdentity_malformed_duration(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_malformed_duration" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "DurationSeconds": {"notanumber"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MalformedInput()) + }) +} + +func IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_wrong_version_is_invalid_action" + cfg := stsUnauthConfig(testName, url.Values{"Action": {"AssumeRoleWithWebIdentity"}, "Version": {"2010-05-08"}}) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.InvalidAction("AssumeRoleWithWebIdentity", "2010-05-08")) + }) +} + +func IAMAssumeRoleWithWebIdentity_malformed_token(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_malformed_token" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", "not-a-real-jwt-token", 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenMalformed()) + }) +} + +func IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_duration_exceeds_role_max" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + // The role's default MaxSessionDuration is 3600. + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 7200) + return checkIAMApiErr(assumeErr, iamerr.DurationExceedsMaxSessionDuration()) + }) +} + +func IAMAssumeRoleWithWebIdentity_nonexistent_role(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_nonexistent_role" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + roleArn := "arn:aws:iam::000000000000:role/" + genRandString(16) + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_no_matching_principal(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_no_matching_principal" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The trust policy's Federated principal never corresponds to a + // real, registered OIDC provider (it was never created) — reported + // identically to a nonexistent role, never confirming or denying + // whether the role itself exists. + roleName := "dangling-trust-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, + oidcProviderArn("https://never-created-"+genRandString(12)+".example.com")) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_no_issuer_match(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_no_issuer_match" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The Federated principal resolves to a real, registered provider — + // but that provider's own Url doesn't match the token's iss claim. + // Unlike no_matching_principal, this confirms the role exists + // (InvalidIdentityToken instead of AccessDenied). + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, newIAMOIDCProviderURL(), "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "https://different-issuer-" + genRandString(8) + ".example.com", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_condition_failed(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_condition_failed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + providerArn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "condition-failed-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:sub":"expected-user"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "client1", "sub": "someone-else", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_explicit_deny(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_explicit_deny" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + providerArn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "explicit-deny-" + genRandString(12) + // A broad Allow is present, but a Deny statement matching the same + // provider/action/condition takes precedence — reported as + // AccessDenied, never InvalidIdentityToken. + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[`+ + `{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"},`+ + `{"Effect":"Deny","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:sub":"blocked-user"}}}]}`, providerArn, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "client1", "sub": "blocked-user", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +func IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_audience_not_in_client_id_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "allowed-client") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "not-the-allowed-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_empty_client_id_list(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_empty_client_id_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + // No ClientIDList entries at all — can never satisfy the audience + // check, no matter what the token's aud claim is. + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "anything", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +// IAMAssumeRoleWithWebIdentity_idp_communication_error confirms the +// network-dependent signature-verification step is wired all the way +// through the real HTTP action handler: a provider Url that's a loopback IP +// literal is rejected by VerifyWebIdentitySignature's mandatory SSRF guard +// before any real network attempt, deterministically and without requiring +// outbound network access from the test environment — the same technique +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// uses for CreateOpenIDConnectProvider's own auto-fetch path. +func IAMAssumeRoleWithWebIdentity_idp_communication_error(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_idp_communication_error" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, "https://127.0.0.1", "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "https://127.0.0.1", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenIDPCommunicationError()) + }) +} + +func IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_role_arn_path_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // The role is created with the default "/" path, so its real Arn is + // arn:...:role/ — not arn:...:role/some/path/. Only the + // role name (the ARN's final path segment) is used to look the role + // up; the full ARN, path included, must still match the role's + // actual Arn, or trust is never evaluated at all. + roleName := "path-mismatch-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, + oidcProviderArn("https://never-created-"+genRandString(12)+".example.com")) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + roleArn := "arn:aws:iam::000000000000:role/some/path/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", validWebIdentityToken, 0) + return checkIAMApiErr(assumeErr, iamerr.AccessDeniedAssumeRoleWithWebIdentity()) + }) +} + +// IAMAssumeRoleWithWebIdentity_policy_arns_rejected and +// IAMAssumeRoleWithWebIdentity_provider_id_rejected confirm PolicyArns and +// ProviderId — valid AssumeRoleWithWebIdentity parameters this +// implementation doesn't support — are rejected outright rather than +// silently ignored. Both checks run before the role is even looked up, so +// (matching the other request-validation tests above) RoleArn need not name +// a real role. +func IAMAssumeRoleWithWebIdentity_policy_arns_rejected(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_policy_arns_rejected" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + "PolicyArns.member.1.arn": {"arn:aws:iam::000000000000:policy/some-policy"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.UnsupportedParameter("PolicyArns")) + }) +} + +func IAMAssumeRoleWithWebIdentity_provider_id_rejected(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_provider_id_rejected" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "ProviderId": {"www.amazon.com"}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.UnsupportedParameter("ProviderId")) + }) +} + +func IAMAssumeRoleWithWebIdentity_session_policy_too_large(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_session_policy_too_large" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, "Policy": {genRandString(2049)}, + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.ValueTooLong("policy", 2048)) + }) +} + +func IAMAssumeRoleWithWebIdentity_session_policy_invalid(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_session_policy_invalid" + cfg := stsUnauthConfig(testName, url.Values{ + "Action": {"AssumeRoleWithWebIdentity"}, "RoleArn": {"arn:aws:iam::000000000000:role/does-not-exist"}, + "RoleSessionName": {"session1"}, "WebIdentityToken": {validWebIdentityToken}, + "Policy": {`{"Version":"2012-10-17"}`}, // no Statement + }) + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.MalformedPolicyDocument("Syntax errors in policy.")) + }) +} + +func IAMAssumeRoleWithWebIdentity_oaud_condition_matches(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_oaud_condition_matches" + return iamActionHandler(s, testName, func(client *iam.Client) error { + // A loopback provider URL guarantees a deterministic + // InvalidIdentityToken IDP-communication error once the request + // reaches the network-dependent signature-verification step — + // reaching that far (rather than being rejected earlier by trust + // evaluation) is what confirms the oaud Condition below matched. + providerURL := "https://127.0.0.7" + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"azp-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "oaud-match-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:oaud":"backend-project"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + // azp overrides aud as the effective audience (checked against the + // provider's ClientIDList below), exposing the original aud + // ("backend-project") for the oaud mapping instead. + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "backend-project", "azp": "azp-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenIDPCommunicationError()) + }) +} + +func IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"azp-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + defer deleteOIDCProvider(client, providerArn) + + host := trimProviderScheme(providerURL) + roleName := "oaud-mismatch-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:oaud":"backend-project"}}}]}`, providerArn, host) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + return err + } + defer deleteIAMRole(client, roleName) + + // Original aud is "different-project", not "backend-project" — the + // azp-effective audience still matches the provider's ClientIDList, + // so only the oaud Condition is what fails this request. + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL, "aud": "different-project", "azp": "azp-client", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + roleArn := "arn:aws:iam::000000000000:role/" + roleName + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": providerURL + "/", "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +func IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + roleArn, cleanup, err := createTestRoleForWebIdentityTrust(client, providerURL, "client1") + if err != nil { + return err + } + defer cleanup() + + token, err := webIdentityTokenWithClaims(map[string]any{ + "iss": "http://" + trimProviderScheme(providerURL), "aud": "client1", "sub": "user1", "exp": 9999999999, + }) + if err != nil { + return err + } + + _, assumeErr := assumeRoleWithWebIdentity(s, roleArn, "session1", token, 0) + return checkIAMApiErr(assumeErr, iamerr.InvalidIdentityTokenClaims()) + }) +} + +// createTestRoleForWebIdentityTrust registers a fresh OIDC provider at +// providerURL (with clientID in its ClientIDList, unless clientID is +// empty) and a role whose trust policy allows sts:AssumeRoleWithWebIdentity +// for that provider with no Condition, returning the role's ARN and a +// cleanup function that removes both. +func createTestRoleForWebIdentityTrust(client *iam.Client, providerURL, clientID string) (roleArn string, cleanup func(), err error) { + var clientIDs []string + if clientID != "" { + clientIDs = []string{clientID} + } + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", nil, err + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + roleName := "web-identity-trust-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, providerArn) + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + deleteOIDCProvider(client, providerArn) + return "", nil, err + } + + cleanup = func() { + deleteIAMRole(client, roleName) + deleteOIDCProvider(client, providerArn) + } + return "arn:aws:iam::000000000000:role/" + roleName, cleanup, nil +} + +// assumeRoleWithWebIdentity calls AssumeRoleWithWebIdentity through a real +// STS SDK client — the action needs no credentials, so this works +// regardless of what (if anything) s itself is configured to sign with. +// durationSeconds of 0 omits DurationSeconds entirely (STS's own default +// applies). +func assumeRoleWithWebIdentity(s *S3Conf, roleArn, sessionName, token string, durationSeconds int32) (*sts.AssumeRoleWithWebIdentityOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + + input := &sts.AssumeRoleWithWebIdentityInput{ + RoleArn: &roleArn, + RoleSessionName: &sessionName, + WebIdentityToken: &token, + } + if durationSeconds > 0 { + input.DurationSeconds = aws.Int32(durationSeconds) + } + return s.GetSTSClient().AssumeRoleWithWebIdentity(ctx, input) +} + +// trimProviderScheme mirrors iamutil.WebIdentityIssuer's scheme-stripping, +// for building Condition context keys (":") against a +// provider's stored (scheme-stripped) Url. +func trimProviderScheme(rawURL string) string { + for _, prefix := range []string{"https://", "http://"} { + if len(rawURL) > len(prefix) && rawURL[:len(prefix)] == prefix { + return rawURL[len(prefix):] + } + } + return rawURL +} diff --git a/tests/integration/iam_get_caller_identity.go b/tests/integration/iam_get_caller_identity.go new file mode 100644 index 00000000..c8959898 --- /dev/null +++ b/tests/integration/iam_get_caller_identity.go @@ -0,0 +1,176 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/versity/versitygw/iamapi/iamerr" +) + +// getCallerIdentity calls GetCallerIdentity through a real STS SDK client +// configured with access/secret. +func getCallerIdentity(cfg S3Conf, access, secret string) (*sts.GetCallerIdentityOutput, error) { + cfg.awsID = access + cfg.awsSecret = secret + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return cfg.GetSTSClient().GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} + +func IAMGetCallerIdentity_root_success(s *S3Conf) error { + testName := "IAMGetCallerIdentity_root_success" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + out, err := getCallerIdentity(*s, s.awsID, s.awsSecret) + if err != nil { + return err + } + wantArn := "arn:aws:iam::000000000000:root" + if aws.ToString(out.Arn) != wantArn { + return fmt.Errorf("expected Arn %q, instead got %q", wantArn, aws.ToString(out.Arn)) + } + if aws.ToString(out.UserId) != "000000000000" { + return fmt.Errorf("expected UserId %q, instead got %q", "000000000000", aws.ToString(out.UserId)) + } + if aws.ToString(out.Account) != "000000000000" { + return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account)) + } + return nil + }) +} + +func IAMGetCallerIdentity_user_success(s *S3Conf) error { + testName := "IAMGetCallerIdentity_user_success" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + userName := newIAMUserName() + createOut, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}) + if err != nil { + return err + } + defer func() { + if delErr := deleteIAMUserAndAccessKeys(client, userName); delErr != nil { + err = fmt.Errorf("%w (also: delete user: %v)", err, delErr) + } + }() + userArn := aws.ToString(createOut.User.Arn) + userID := aws.ToString(createOut.User.UserId) + + keyOut, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName}) + if err != nil { + return err + } + + out, err := getCallerIdentity(*s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey)) + if err != nil { + return err + } + if aws.ToString(out.Arn) != userArn { + return fmt.Errorf("expected Arn %q, instead got %q", userArn, aws.ToString(out.Arn)) + } + if aws.ToString(out.UserId) != userID { + return fmt.Errorf("expected UserId %q, instead got %q", userID, aws.ToString(out.UserId)) + } + if aws.ToString(out.Account) != "000000000000" { + return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account)) + } + return nil + }) +} + +func IAMGetCallerIdentity_unknown_access_key(s *S3Conf) error { + testName := "IAMGetCallerIdentity_unknown_access_key" + return iamActionHandler(s, testName, func(_ *iam.Client) error { + _, err := getCallerIdentity(*s, "AKIAuNKNOWNACCESSKEYID", "does-not-matter") + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)) + }) +} + +func IAMGetCallerIdentity_no_auth(s *S3Conf) error { + testName := "IAMGetCallerIdentity_no_auth" + runF(testName) + + body := []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode()) + req, err := http.NewRequest(http.MethodPost, s.endpoint+"/", bytes.NewReader(body)) + if err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.httpClient.Do(req) + if err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + if err := checkSTSApiErr(resp, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)); err != nil { + failF("%v: %v", testName, err) + return fmt.Errorf("%v: %w", testName, err) + } + + passF(testName) + return nil +} + +func IAMGetCallerIdentity_wrong_version_is_invalid_action(s *S3Conf) error { + testName := "IAMGetCallerIdentity_wrong_version_is_invalid_action" + cfg := &authConfig{ + testName: testName, + method: http.MethodPost, + service: "sts", + region: iamAuthRegion, + body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2010-05-08"}}.Encode()), + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + } + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.InvalidAction("GetCallerIdentity", "2010-05-08")) + }) +} + +// IAMGetCallerIdentity_incorrect_service_scope confirms the shared sigv4 +// auth pipeline reports the STS-specific service name ("sts", not "iam") +// when GetCallerIdentity is signed with a Credential scoped to the wrong +// service. +func IAMGetCallerIdentity_incorrect_service_scope(s *S3Conf) error { + testName := "IAMGetCallerIdentity_incorrect_service_scope" + cfg := &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", // wrong: GetCallerIdentity expects "sts" + region: iamAuthRegion, + body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode()), + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + } + return authHandler(s, cfg, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + return checkSTSApiErr(resp, iamerr.IncorrectServiceScope("sts")) + }) +} diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index 71506012..d47acb4d 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -29,6 +29,7 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go/middleware" ) @@ -158,6 +159,11 @@ func (c *S3Conf) GetIAMClient() *iam.Client { return iam.NewFromConfig(c.Config()) } +// GetSTSClient returns an SDK client for STS actions +func (c *S3Conf) GetSTSClient() *sts.Client { + return sts.NewFromConfig(c.Config()) +} + func (c *S3Conf) GetPresignClient() *s3.PresignClient { return s3.NewPresignClient(c.GetClient()) } From 5545067a6a4c8b5dc73c5f4fb12d5e86388521a9 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 5 Aug 2026 17:23:08 +0400 Subject: [PATCH 7/7] feat: add live GitHub OIDC end-to-end test for AssumeRoleWithWebIdentity Add IAMAssumeRoleWithWebIdentity_github_oidc_live, the only web-identity test that exercises AssumeRoleWithWebIdentity against a real external OIDC provider end-to-end: GitHub Actions' own issuer, with real discovery-document fetch, JWKS fetch, RS256 signature verification, claims mapping, and session credential issuance. Every other web-identity test in the suite uses a fake token that never reaches real signature verification. The test registers a throwaway OIDC provider and trust role scoped to this repo (via a distinct test audience and repo-scoped sub condition), fetches a real ID token from GitHub's runtime endpoint, assumes the role, and confirms the issued session credentials work with a follow-up GetCallerIdentity call. It cleans up the role and provider unconditionally and skips itself when run outside a GitHub Actions job with id-token: write permission (e.g. local runs or fork PRs, where GitHub downgrades OIDC permissions to read-only). Add functional-iam-oidc.yml to run this test in CI on push to main and on same-repo pull_request runs, isolated from the full iam suite since it's the only test needing id-token: write. Add a SKIP counter and skipF() alongside the existing runF/passF/failF, and report it in the final RAN/PASS/FAIL summary, so a test opting out via skipF() (as this one does when OIDC env vars aren't present) is visible instead of silently absent from the count. --- .github/workflows/functional-iam-oidc.yml | 88 +++++++ cmd/versitygw/test.go | 4 +- tests/integration/group-tests.go | 2 + ...sume_role_with_web_identity_github_oidc.go | 242 ++++++++++++++++++ tests/integration/output.go | 15 +- 5 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/functional-iam-oidc.yml create mode 100644 tests/integration/iam_assume_role_with_web_identity_github_oidc.go diff --git a/.github/workflows/functional-iam-oidc.yml b/.github/workflows/functional-iam-oidc.yml new file mode 100644 index 00000000..0c7a11d9 --- /dev/null +++ b/.github/workflows/functional-iam-oidc.yml @@ -0,0 +1,88 @@ +name: IAM functional tests (GitHub OIDC live) + +# This workflow exercises AssumeRoleWithWebIdentity against a REAL external +# OIDC identity provider (GitHub Actions' own OIDC issuer) - the one publicly +# reachable, free IdP available from inside our own CI job, so no self-hosted +# IdP container is needed. +# +# Trigger stays plain `pull_request` (never pull_request_target or +# workflow_run) plus `push` to main. On a pull_request run, GitHub itself +# downgrades GITHUB_TOKEN/OIDC permissions to read-only whenever the PR +# comes from a fork - regardless of what this file requests - so +# ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN simply won't +# exist in that case and the test below skips itself. That's the actual +# security boundary here: a hostile fork-PR author cannot use their own PR +# to mint a token scoped to this repo's identity through this workflow. Only +# a same-repo (non-fork) pull_request run, or a push to main, gets real +# credentials and actually exercises the live OIDC flow. +permissions: + contents: read + id-token: write + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + name: RunIAMGitHubOIDCTest + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "stable" + id: go + + - name: Get Dependencies + run: | + go mod download + + - name: Build + run: | + make testbin + + - name: Run GitHub OIDC live web-identity test + run: | + set -Eeuo pipefail + + IAM_PID="" + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$IAM_PID" ]] && kill -0 "$IAM_PID" 2>/dev/null; then + kill "$IAM_PID" 2>/dev/null || true + fi + if [[ -n "$IAM_PID" ]]; then + wait "$IAM_PID" 2>/dev/null || true + fi + exit "$status" + } + trap cleanup EXIT + + mkdir -p /tmp/iam-oidc + ./versitygw --health /healthz -p :7078 -a user -s pass iam --dir /tmp/iam-oidc & + IAM_PID=$! + + ready="" + for _ in {1..50}; do + if curl --fail --silent --max-time 1 http://127.0.0.1:7078/healthz >/dev/null 2>&1; then + ready=1 + break + fi + if ! kill -0 "$IAM_PID" 2>/dev/null; then + echo "IAM API server stopped before becoming ready" >&2 + exit 1 + fi + sleep 0.2 + done + if [[ -z "$ready" ]]; then + echo "timed out waiting for IAM API server" >&2 + exit 1 + fi + + ./versitygw test -a user -s pass -e http://127.0.0.1:7078 IAMAssumeRoleWithWebIdentity_github_oidc_live diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 44d38ec3..f4996320 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -415,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()) } @@ -457,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()) } diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 23d8d0ff..1c73dd50 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1467,6 +1467,7 @@ func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_github_oidc_live) } func TestIAMGetCallerIdentity(ts *TestState) { @@ -2183,6 +2184,7 @@ func GetIntTests() IntTests { "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, + "IAMAssumeRoleWithWebIdentity_github_oidc_live": IAMAssumeRoleWithWebIdentity_github_oidc_live, "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, diff --git a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go new file mode 100644 index 00000000..61a5600e --- /dev/null +++ b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go @@ -0,0 +1,242 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +const ( + // githubOIDCIssuerURL is GitHub Actions' own OIDC token issuer: a real, + // publicly reachable HTTPS endpoint with a CA-issued certificate. + githubOIDCIssuerURL = "https://token.actions.githubusercontent.com" + + // githubOIDCTestAudience is deliberately distinct from GitHub's default + // audience (which is the caller's own server URL). If this org ever + // configures a real cloud-provider role trusting + // token.actions.githubusercontent.com for this repo (e.g. for + // publishing/deploys), a leaked test token must not be replayable + // against that unrelated trust relationship - binding the throwaway + // role's trust policy to this audience (instead of GitHub's default) + // is what prevents that. + githubOIDCTestAudience = "versitygw-integration-tests" +) + +// IAMAssumeRoleWithWebIdentity_github_oidc_live exercises +// AssumeRoleWithWebIdentity against a REAL external OIDC identity provider — +// GitHub Actions' own OIDC issuer — end-to-end: discovery-document fetch, +// JWKS fetch, real RS256 signature verification, claims mapping, and +// session credential issuance. It's the only web-identity test that does +// this; every other one in this package uses a fake token that never +// reaches real signature verification. +func IAMAssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_github_oidc_live" + + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + skipF("%v: ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set "+ + "(expected outside a GitHub Actions job with id-token: write permission)", testName) + return nil + } + + return iamActionHandler(s, testName, func(client *iam.Client) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set, but ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are - unexpected environment") + } + + roleName, roleArn, cleanup, err := createGitHubOIDCTrust(client, repo) + if err != nil { + return err + } + defer cleanup() + + token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience) + if err != nil { + return err + } + + const sessionName = "github-oidc-live" + assumeOut, err := assumeRoleWithWebIdentity(s, roleArn, sessionName, token, 0) + if err != nil { + // checkIAMApiErr-style wrapping isn't used here since a live + // AssumeRoleWithWebIdentity SDK error carries no token material + // of its own to guard against - it's the request we build + // (never printed) and GitHub's response (never printed either, + // see fetchGitHubIDToken) that could leak the token. + return fmt.Errorf("AssumeRoleWithWebIdentity: %w", err) + } + if assumeOut.Credentials == nil { + return fmt.Errorf("expected Credentials in AssumeRoleWithWebIdentity response") + } + accessKeyID := aws.ToString(assumeOut.Credentials.AccessKeyId) + secretAccessKey := aws.ToString(assumeOut.Credentials.SecretAccessKey) + sessionToken := aws.ToString(assumeOut.Credentials.SessionToken) + if accessKeyID == "" || secretAccessKey == "" || sessionToken == "" { + return fmt.Errorf("expected a full AccessKeyId/SecretAccessKey/SessionToken triple in AssumeRoleWithWebIdentity response") + } + + wantArn := fmt.Sprintf("arn:aws:sts::000000000000:assumed-role/%s/%s", roleName, sessionName) + if aws.ToString(assumeOut.AssumedRoleUser.Arn) != wantArn { + return fmt.Errorf("expected AssumedRoleUser.Arn %q, instead got %q", wantArn, aws.ToString(assumeOut.AssumedRoleUser.Arn)) + } + + // A follow-up call authenticated with the session credentials + // AssumeRoleWithWebIdentity just issued proves the whole chain - + // discovery, JWKS, signature verification, claims mapping, and + // session creds - actually works, not just that a 200 came back. + callerOut, err := getCallerIdentityWithSessionCreds(*s, accessKeyID, secretAccessKey, sessionToken) + if err != nil { + return fmt.Errorf("GetCallerIdentity with assumed-role session credentials: %w", err) + } + if aws.ToString(callerOut.Arn) != wantArn { + return fmt.Errorf("GetCallerIdentity: expected Arn %q, instead got %q", wantArn, aws.ToString(callerOut.Arn)) + } + return nil + }) +} + +// createGitHubOIDCTrust registers a throwaway OIDC provider for GitHub +// Actions' own issuer (ThumbprintList omitted, exercising +// CreateOpenIDConnectProvider's autofetch-and-CA-verify path against a real +// publicly reachable HTTPS endpoint instead of thumbprint pinning) and a +// throwaway role trusting it, returning the role's name, its ARN, and a +// cleanup func that removes both unconditionally. +// +// The trust policy's Condition requires both: +// - the effective audience to equal githubOIDCTestAudience (not GitHub's +// default audience - see that constant's doc comment), and +// - the sub claim to match "repo::*". +// +// The sub match is a repo-wide wildcard rather than pinning an exact +// ref/event suffix: GitHub's sub claim differs by trigger and branch (e.g. +// "repo:o/r:pull_request" for a pull_request event vs. +// "repo:o/r:ref:refs/heads/main" for a push to main), and pinning one exact +// form would make this test fail depending on how it was triggered. That +// tradeoff only holds because this role is created and deleted within a +// single test run - the same repo-wide wildcard left in a real production +// trust policy would grant every workflow run in the repo, on any branch, +// the same trust, which is far too broad outside this throwaway context. +func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn string, cleanup func(), err error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(githubOIDCIssuerURL), + ClientIDList: []string{githubOIDCTestAudience}, + }) + if err != nil { + return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err) + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + host := trimProviderScheme(githubOIDCIssuerURL) + roleName = "github-oidc-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:aud":%q},"StringLike":{"%s:sub":%q}}}]}`, + providerArn, host, githubOIDCTestAudience, host, "repo:"+repo+":*") + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + deleteOIDCProvider(client, providerArn) + return "", "", nil, fmt.Errorf("create GitHub OIDC trust role: %w", err) + } + + roleArn = "arn:aws:iam::000000000000:role/" + roleName + cleanup = func() { + deleteIAMRole(client, roleName) + deleteOIDCProvider(client, providerArn) + } + return roleName, roleArn, cleanup, nil +} + +// githubIDTokenResponse is the JSON body GitHub's runtime ID-token endpoint +// returns: {"value": "", "count": }. Only value is needed here. +type githubIDTokenResponse struct { + Value string `json:"value"` +} + +// fetchGitHubIDToken fetches a real, signed OIDC ID token for audience from +// GitHub Actions' runtime token endpoint (requestURL/requestToken are +// ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN, only present +// inside a GitHub Actions job with id-token: write permission). +// +// The returned token is a real, unmasked bearer credential - unlike a +// secrets.* value, GitHub does not scrub it from logs automatically since it +// never appears in the workflow YAML. Every error path here is deliberately +// built from fixed strings and status codes only, never from the response +// body or the request's Authorization header, so a failure here can never +// leak the token into CI output. +func fetchGitHubIDToken(requestURL, requestToken, audience string) (string, error) { + parsed, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("parse ACTIONS_ID_TOKEN_REQUEST_URL: invalid URL") + } + q := parsed.Query() + q.Set("audience", audience) + parsed.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return "", fmt.Errorf("build GitHub OIDC token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json; api-version=2.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch GitHub OIDC token: request failed") + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("read GitHub OIDC token response: failed after status %d", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub OIDC token endpoint returned status %d", resp.StatusCode) + } + + var out githubIDTokenResponse + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("parse GitHub OIDC token response: malformed JSON") + } + if out.Value == "" { + return "", fmt.Errorf("GitHub OIDC token endpoint returned an empty token value") + } + return out.Value, nil +} + +// getCallerIdentityWithSessionCreds calls GetCallerIdentity authenticated +// with a full access/secret/session-token triple. +func getCallerIdentityWithSessionCreds(cfg S3Conf, access, secret, token string) (*sts.GetCallerIdentityOutput, error) { + cfg.awsID = access + cfg.awsSecret = secret + stsCfg := cfg.Config() + stsCfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return sts.NewFromConfig(stsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} diff --git a/tests/integration/output.go b/tests/integration/output.go index a0b295f4..0d3506c2 100644 --- a/tests/integration/output.go +++ b/tests/integration/output.go @@ -20,16 +20,18 @@ import ( ) var ( - colorReset = "\033[0m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorCyan = "\033[36m" + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorCyan = "\033[36m" + colorYellow = "\033[33m" ) var ( RunCount atomic.Uint32 PassCount atomic.Uint32 FailCount atomic.Uint32 + SkipCount atomic.Uint32 ) func runF(format string, a ...any) { @@ -46,3 +48,8 @@ func passF(format string, a ...any) { PassCount.Add(1) fmt.Printf(colorGreen+"PASS "+colorReset+format+"\n", a...) } + +func skipF(format string, a ...any) { + SkipCount.Add(1) + fmt.Printf(colorYellow+"SKIP "+colorReset+format+"\n", a...) +}