From a884d52af4ba07f656f8bf577d4e7d3060544f00 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 6 Jul 2026 22:59:24 +0400 Subject: [PATCH] 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 f73d1a21..70e29f34 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1165,6 +1165,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) } @@ -1180,6 +1181,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) @@ -1188,6 +1247,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) { @@ -1572,6 +1636,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, @@ -1582,6 +1647,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) +}