diff --git a/iamapi/controller.go b/iamapi/controller.go index 5b06eef1..eb042e76 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -235,6 +235,88 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { }}, nil } +// TagUser adds or replaces tags on an existing user. AWS validates the +// request in full before it ever looks the user up, so a malformed tag on a +// non-existent user reports the tag error, not NoSuchEntity. +func (c IAMApiController) TagUser(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "TagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + if len(tags) == 0 { + debuglogger.Logf("missing required TagUser parameter: Tags") + return nil, iamerr.MissingValue("tags") + } + + if err := c.store.TagUser(ctx.Context(), userName, tags); err != nil { + debuglogger.Logf("failed to tag IAM user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.TagUserResponse{}}, nil +} + +// UntagUser removes the named tags from an existing user. Removal is +// idempotent: a key naming no current tag is not an error. +func (c IAMApiController) UntagUser(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "UntagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + tagKeys, err := iamutil.ParseTagKeys(ctx) + if err != nil { + return nil, err + } + if len(tagKeys) == 0 { + debuglogger.Logf("missing required UntagUser parameter: TagKeys") + return nil, iamerr.MissingValue("tagKeys") + } + + if err := c.store.UntagUser(ctx.Context(), userName, tagKeys); err != nil { + debuglogger.Logf("failed to untag IAM user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.UntagUserResponse{}}, nil +} + +func (c IAMApiController) ListUserTags(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "ListUserTags", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserTags") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListUserTags(ctx.Context(), storage.ListUserTagsInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM user %q tags: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListUserTagsResponse{ + Result: types.ListUserTagsResult{ + Tags: types.Tags{Members: out.Tags}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) if err != nil { diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 39bf5156..38e4af80 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -21,6 +21,7 @@ import ( "encoding/hex" "encoding/json" "encoding/xml" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -364,8 +365,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { "Tags.member.1.Value": {"test"}, }, status: http.StatusBadRequest, - code: "MissingParameter", - message: "The request must contain the parameter Tags.member.1.Key.", + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", }, { name: "missing tag value", @@ -375,8 +376,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { "Tags.member.1.Key": {"env"}, }, status: http.StatusBadRequest, - code: "MissingParameter", - message: "The request must contain the parameter Tags.member.1.Value.", + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", }, } @@ -533,6 +534,435 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.") } +func TestIAMApiControllerUserTagLifecycle(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)) + } + + if got := listUserTags(t, server, "alice"); len(got.Tags.Members) != 0 || got.IsTruncated { + t.Fatalf("ListUserTags on a fresh user = %#v, want no tags", got) + } + + tagUser(t, server, "alice", map[string]string{"env": "prod", "team": "storage", "empty": ""}) + + got := listUserTags(t, server, "alice") + // Sorted by key, regardless of the order they were added in. + want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want) + } + + // A repeated key replaces its value in place; a differently-cased key + // is the same tag, and the new casing wins. + tagUser(t, server, "alice", map[string]string{"env": "staging"}) + tagUser(t, server, "alice", map[string]string{"TEAM": "compute"}) + + got = listUserTags(t, server, "alice") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want) + } + + // GetUser reports the same tags the tag actions maintain. + getUser := doIAMAction(t, server, url.Values{"Action": {"GetUser"}, "UserName": {"alice"}}) + var getResp struct { + Result struct{ User iamtypes.User } `xml:"GetUserResult"` + } + unmarshalXML(t, readBody(t, getUser), &getResp) + if len(getResp.Result.User.Tags) != 3 { + t.Fatalf("GetUser Tags = %#v, want 3 tags", getResp.Result.User.Tags) + } + + // Removal is case-insensitive, and a key naming no tag is not an error. + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagUser"}, + "UserName": {"alice"}, + "TagKeys.member.1": {"EnV"}, + "TagKeys.member.2": {"never-existed"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagUser status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + + got = listUserTags(t, server, "alice") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want) + } +} + +func TestIAMApiControllerListUserTagsPagination(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)) + } + tagUser(t, server, "alice", map[string]string{"a": "1", "b": "2", "c": "3"}) + + var seen []iamtypes.Tag + marker := "" + for page := 1; ; page++ { + params := url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1"}} + if marker != "" { + params.Set("Marker", marker) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out struct { + Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + + if len(out.Result.Tags.Members) != 1 { + t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members)) + } + seen = append(seen, out.Result.Tags.Members...) + if !out.Result.IsTruncated { + if out.Result.Marker != "" { + t.Fatalf("final page Marker = %q, want empty", out.Result.Marker) + } + break + } + marker = out.Result.Marker + } + + want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}} + if !slices.Equal(seen, want) { + t.Fatalf("paged tags = %#v, want %#v", seen, want) + } +} + +func TestIAMApiControllerTagUserExceedsQuota(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)) + } + + atQuota := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}} + for i := 1; i <= storage.MaxTagsPerUser; i++ { + atQuota.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + atQuota.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + if resp := doIAMAction(t, server, atQuota); resp.StatusCode != http.StatusOK { + t.Fatalf("TagUser with %d tags status = %d, body=%s", storage.MaxTagsPerUser, resp.StatusCode, readBody(t, resp)) + } + + // Replacing an existing key at the quota is fine: the total doesn't grow. + replace := doIAMAction(t, server, url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"}, + }) + if replace.StatusCode != http.StatusOK { + t.Fatalf("TagUser replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace)) + } + + // One more distinct key does not fit. + overflow := doIAMAction(t, server, url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"overflow"}, "Tags.member.1.Value": {"x"}, + }) + requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded", + "The number of tags has reached the maximum limit.") +} + +func TestIAMApiControllerUserTagValidationErrors(t *testing.T) { + tooManyTags := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + tooManyTagKeys := url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i)) + } + + tests := []struct { + name string + setupUser bool + params url.Values + status int + code string + message string + }{ + { + name: "tag missing user name", + params: url.Values{"Action": {"TagUser"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing tags", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing key", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing value", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag empty key", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1", + }, + { + name: "tag key too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "tag invalid key characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`, + }, + { + name: "tag value too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", + }, + { + name: "tag invalid value characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]*`, + }, + { + name: "tag duplicate keys", + setupUser: true, + params: url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"a"}, + "Tags.member.2.Key": {"ENV"}, "Tags.member.2.Value": {"b"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + { + name: "tag too many tags", + setupUser: true, + params: tooManyTags, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "tag invalid user name characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"bad!name"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "tag user name too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {strings.Repeat("u", 129)}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + // A malformed tag is reported before the user is looked up. + name: "tag non existing user with invalid tag", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`, + }, + { + name: "tag non existing user", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + { + name: "untag missing user name", + params: url.Values{"Action": {"UntagUser"}, "TagKeys.member.1": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag missing tag keys", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag empty key", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {""}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag key too long", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {strings.Repeat("k", 129)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag invalid key characters", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {"bad*key"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag too many tag keys", + setupUser: true, + params: tooManyTagKeys, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "untag non existing user", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"nosuchuser"}, "TagKeys.member.1": {"env"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + { + name: "list missing user name", + params: url.Values{"Action": {"ListUserTags"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "list max items too small", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"0"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + }, + { + name: "list max items too large", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + }, + { + name: "list max items not a number", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"abc"}}, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "list non existing user", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"nosuchuser"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + } + + 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) + }) + } +} + +// invalidTagKeysMessage is UntagUser's single response to every malformed +// TagKeys member, whatever the specific constraint that failed. +const invalidTagKeysMessage = `1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+, Member must not be null]` + +func tagUser(t *testing.T, server *IAMApiServer, userName string, tags map[string]string) { + t.Helper() + + params := url.Values{"Action": {"TagUser"}, "UserName": {userName}} + i := 1 + for key, value := range tags { + params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key) + params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value) + i++ + } + + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("TagUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func listUserTags(t *testing.T, server *IAMApiServer, userName string) iamtypes.ListUserTagsResult { + t.Helper() + + resp := doIAMAction(t, server, url.Values{"Action": {"ListUserTags"}, "UserName": {userName}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out struct { + Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + return out.Result +} + func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -803,7 +1233,7 @@ func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) { 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", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", }, } @@ -1616,7 +2046,7 @@ func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) { 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", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", }, } diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 533b6bd3..0c5cd87b 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -57,11 +57,16 @@ const ( ErrInvalidContentLength ErrThrottling ErrTooManyTags + ErrTooManyTagKeys + ErrInvalidTagKeys + ErrTagLimitExceeded ErrInvalidPathPrefix ErrDuplicateTagKeys ErrInvalidAccessKeyIDChars ErrDeleteConflict ErrDeleteConflictPolicies + ErrMaxItemsTooLow + ErrMaxItemsTooHigh ) type APIError interface { @@ -221,6 +226,36 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", HTTPStatusCode: http.StatusBadRequest, }, + ErrTooManyTagKeys: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidTagKeys: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+, Member must not be null]", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrTagLimitExceeded: { + Type: TypeSender, + Code: "LimitExceeded", + Message: "The number of tags has reached the maximum limit.", + HTTPStatusCode: http.StatusConflict, + }, + ErrMaxItemsTooLow: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMaxItemsTooHigh: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + HTTPStatusCode: http.StatusBadRequest, + }, ErrDuplicateTagKeys: { Type: TypeSender, Code: "InvalidInput", @@ -405,10 +440,6 @@ func PathTooLong(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 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)) } @@ -429,6 +460,22 @@ func InvalidTagKey(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+", index)) } +// MissingTagKey reports a Tags member supplying a Value with no Key. +func MissingTagKey(index int) Error { + return MissingValue(fmt.Sprintf("tags.%d.member.key", index)) +} + +// MissingTagValue reports a Tags member supplying a Key with no Value. A +// tag value may be empty, but the parameter itself must be present. +func MissingTagValue(index int) Error { + return MissingValue(fmt.Sprintf("tags.%d.member.value", index)) +} + +// TagKeyTooShort reports an empty tag key +func TagKeyTooShort(index int) Error { + return ValueTooShort(fmt.Sprintf("tags.%d.member.key", index), 1) +} + func TagValueTooLong(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", index)) } diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go index 71510693..70511f27 100644 --- a/iamapi/internal/iammiddleware/policy.go +++ b/iamapi/internal/iammiddleware/policy.go @@ -173,7 +173,8 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string case "GetUser": return getUserResource(ctx, store) case "DeleteUser", "UpdateUser", "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey", - "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies": + "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies", + "TagUser", "UntagUser", "ListUserTags": return existingUserResource(ctx, store) case "GetAccessKeyLastUsed": return accessKeyOwnerResource(ctx, store) @@ -380,8 +381,10 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri } switch action { - case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider": + case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider", "TagUser": addRequestTagContext(condCtx, ctx) + case "UntagUser": + addTagKeysContext(condCtx, ctx) } return condCtx @@ -450,11 +453,11 @@ func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) { // 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. +// for the actual create or tag 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 write 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 { @@ -468,6 +471,17 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { condCtx["aws:TagKeys"] = keys } +// addTagKeysContext populates aws:TagKeys from UntagUser's TagKeys +// parameter. UntagUser supplies keys without values, so aws:TagKeys is the +// only tag key it can be scoped by +func addTagKeysContext(condCtx map[string][]string, ctx fiber.Ctx) { + keys, err := iamutil.ParseTagKeys(ctx) + if err != nil || len(keys) == 0 { + return + } + 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 { diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index e1c2d981..a9db7151 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -29,18 +29,19 @@ import ( ) const ( - DefaultAccountID = "000000000000" - DefaultUserPath = "/" - DefaultMaxItems = 100 - MaxListItems = 1000 - MaxUserNameLen = 64 - MaxUserLookupLen = 128 - MaxPathLen = 512 - userIDPrefix = "AIDA" - userIDRandomLen = 17 - userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - maxTagKeyLen = 128 - maxTagValLen = 256 + DefaultAccountID = "000000000000" + DefaultUserPath = "/" + DefaultMaxItems = 100 + MaxListItems = 1000 + MaxUserNameLen = 64 + MaxUserLookupLen = 128 + MaxPathLen = 512 + userIDPrefix = "AIDA" + userIDRandomLen = 17 + userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + maxTagKeyLen = 128 + maxTagValLen = 256 + MaxTagMembersPerRequest = 50 roleIDPrefix = "AROA" roleIDRandomLen = 17 @@ -184,15 +185,25 @@ func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) { } 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) + if err != nil { + debuglogger.Logf("malformed %s MaxItems value %q: %v", operation, rawMaxItems, err) + return 0, iamerr.MalformedInput() + } + if parsed < 1 { + debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems) + return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow) + } + if parsed > MaxListItems { + debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems) + return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh) } return int32(parsed), nil } -// ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list. +// ParseTags reads IAM tag members from the request (up to +// MaxTagMembersPerRequest), validates each, and returns the list. Tag keys +// are compared case-insensitively for duplicate detection, matching AWS. func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { var tags []types.Tag seen := map[string]struct{}{} @@ -206,17 +217,17 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { if !hasKey && !hasValue { break } - if len(tags) >= 50 { - debuglogger.Logf("IAM user tag count exceeds maximum: max=%d", 50) + if len(tags) >= MaxTagMembersPerRequest { + debuglogger.Logf("IAM tag count exceeds maximum: max=%d", MaxTagMembersPerRequest) return nil, iamerr.GetAPIError(iamerr.ErrTooManyTags) } if !hasKey { debuglogger.Logf("missing required IAM tag parameter: %s", keyName) - return nil, iamerr.MissingParameter(keyName) + return nil, iamerr.MissingTagKey(i) } if !hasValue { debuglogger.Logf("missing required IAM tag parameter: %s", valueName) - return nil, iamerr.MissingParameter(valueName) + return nil, iamerr.MissingTagValue(i) } if err := validateTag(i, key, value); err != nil { return nil, err @@ -235,6 +246,33 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { return tags, nil } +// ParseTagKeys reads UntagUser's TagKeys members from the request (up to +// MaxTagMembersPerRequest), validates each, and returns the list. Unlike +// ParseTags, duplicate keys are accepted: removing the same key twice is a +// no-op, so AWS has no reason to reject it. +func ParseTagKeys(ctx fiber.Ctx) ([]string, error) { + var keys []string + + for i := 1; ; i++ { + key, ok := RequestParam(ctx, fmt.Sprintf("TagKeys.member.%d", i)) + if !ok { + break + } + if len(keys) >= MaxTagMembersPerRequest { + debuglogger.Logf("IAM tag key count exceeds maximum: max=%d", MaxTagMembersPerRequest) + return nil, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys) + } + if key == "" || len(key) > maxTagKeyLen || !tagKeyPattern.MatchString(key) { + debuglogger.Logf("invalid IAM tag key: index=%d value=%q", i, key) + return nil, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys) + } + + keys = append(keys, key) + } + + return keys, nil +} + // 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. @@ -329,7 +367,11 @@ func validateTag(index int, key, value string) error { debuglogger.Logf("IAM tag key exceeds maximum length: index=%d length=%d max=%d", index, len(key), maxTagKeyLen) return iamerr.TagKeyTooLong(index) } - if key == "" || !tagKeyPattern.MatchString(key) { + if key == "" { + debuglogger.Logf("empty IAM tag key: index=%d", index) + return iamerr.TagKeyTooShort(index) + } + if !tagKeyPattern.MatchString(key) { debuglogger.Logf("invalid IAM tag key: index=%d value=%q", index, key) return iamerr.InvalidTagKey(index) } diff --git a/iamapi/router.go b/iamapi/router.go index 6820cd1c..8453d7d4 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -65,6 +65,10 @@ func (r *IAMApiRouter) Init() { "GetUser": r.Ctrl.GetUser, "ListUsers": r.Ctrl.ListUsers, "UpdateUser": r.Ctrl.UpdateUser, + // User Tagging + "TagUser": r.Ctrl.TagUser, + "UntagUser": r.Ctrl.UntagUser, + "ListUserTags": r.Ctrl.ListUserTags, // User Access Key CRUD "CreateAccessKey": r.Ctrl.CreateAccessKey, "UpdateAccessKey": r.Ctrl.UpdateAccessKey, diff --git a/iamapi/storage/common.go b/iamapi/storage/common.go new file mode 100644 index 00000000..7969b9aa --- /dev/null +++ b/iamapi/storage/common.go @@ -0,0 +1,272 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package storage + +import ( + "errors" + "slices" + "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 + +// MaxTagsPerUser is the maximum number of tags a single IAM user may carry +// at once, matching the AWS IAM quota. +const MaxTagsPerUser = 50 + +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// all of a single IAM user's inline policy documents combined +const MaxInlinePolicyBytesPerUser = 2048 + +// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of +// all of a single IAM role's inline policy documents combined +const MaxInlinePolicyBytesPerRole = 10240 + +// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single +// OIDC provider may hold at once +const MaxClientIDsPerOIDCProvider = 100 + +// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a +// single account may hold +const MaxOIDCProvidersPerAccount = 100 + +// MaxActiveSessionsPerRole bounds how many currently-unexpired +// AssumeRoleWithWebIdentity sessions a single role may have at once. +// AWS manages and rate-limits STS as a hosted service with no +// customer-visible equivalent quota to match for fidelity; this exists +// purely as local resource protection, since without it a single valid +// federated token can be replayed indefinitely to grow the session +// store — every InternalStore rewrite, or Vault KV path/metadata entry — +// without bound. Chosen generously enough to not constrain any legitimate +// workload's concurrent session count. +// +// A var, not a const, so tests can temporarily lower it rather than paying +// the cost of actually creating 1000 sessions to exercise the cap. +var MaxActiveSessionsPerRole = 1000 + +var ( + ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + 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 { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListUsersOutput struct { + Users []types.User + IsTruncated bool + Marker string +} + +type ListUserTagsInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserTagsOutput struct { + Tags []types.Tag + IsTruncated bool + Marker string +} + +type UpdateUserInput struct { + UserName string + NewPath string + NewUserName string + NewArn string +} + +type CreateAccessKeyInput struct { + UserName string + AccessKeyID string + SecretAccessKey string + Status string + CreateDate time.Time +} + +type UpdateAccessKeyInput struct { + UserName string + AccessKeyID string + Status string +} + +type ListAccessKeysInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListAccessKeysOutput struct { + AccessKeys []types.AccessKeyMetadata + IsTruncated bool + Marker string +} + +type GetAccessKeyLastUsedOutput struct { + UserName string + LastUsedDate time.Time + ServiceName string + Region string +} + +type PutUserPolicyInput struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserPoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + +type ListRolesInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListRolesOutput struct { + Roles []types.Role + IsTruncated bool + Marker string +} + +type UpdateAssumeRolePolicyInput struct { + RoleName string + PolicyDocument string +} + +type PutRolePolicyInput struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListRolePoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + +type ListOIDCProvidersOutput struct { + Providers []types.OpenIDConnectProviderListEntry +} + +// mergeTags applies TagUser's merge semantics to existing: an incoming tag +// replaces the existing tag whose key matches case-insensitively — taking +// over its position and its key's casing — and any remaining incoming tag +// is appended in the order supplied. AWS caps the merged total, not the +// request, so replacing a tag on a user already at the cap is allowed. +func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) { + merged := slices.Clone(existing) + for _, tag := range incoming { + if idx := indexOfTagKey(merged, tag.Key); idx >= 0 { + merged[idx] = tag + continue + } + if len(merged) >= MaxTagsPerUser { + return nil, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded) + } + merged = append(merged, tag) + } + return merged, nil +} + +// removeTags applies UntagUser's removal semantics to existing: every tag +// whose key case-insensitively matches one of tagKeys is dropped, and a key +// naming no existing tag is ignored rather than reported. +func removeTags(existing []types.Tag, tagKeys []string) []types.Tag { + return slices.DeleteFunc(slices.Clone(existing), func(tag types.Tag) bool { + return slices.ContainsFunc(tagKeys, func(key string) bool { + return strings.EqualFold(key, tag.Key) + }) + }) +} + +func indexOfTagKey(tags []types.Tag, key string) int { + return slices.IndexFunc(tags, func(tag types.Tag) bool { + return strings.EqualFold(tag.Key, key) + }) +} + +// paginateTags sorts tags by key and applies input's Marker/MaxItems window. +// AWS's own ListUserTags returns tags in an unspecified order (its docs +// claim sorted by key; live responses are not), so this sorts by key: a +// stable order is what makes a Marker meaningful, and it's the order the +// documentation promises. +func paginateTags(tags []types.Tag, input ListUserTagsInput) *ListUserTagsOutput { + sorted := slices.Clone(tags) + slices.SortFunc(sorted, func(a, b types.Tag) int { + return strings.Compare(a.Key, b.Key) + }) + + if input.Marker != "" { + start := len(sorted) + if idx := indexOfTagKey(sorted, input.Marker); idx >= 0 { + start = idx + 1 + } + sorted = sorted[start:] + } + + limit := len(sorted) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserTagsOutput{Tags: sorted[:limit]} + if limit < len(sorted) { + out.IsTruncated = true + out.Marker = out.Tags[limit-1].Key + } + + return out +} + +func unwrapAPIError(err error) error { + var apiErr iamerr.APIError + if errors.As(err, &apiErr) { + return apiErr + } + + return err +} diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 27acfc3a..f3f0ad4d 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -347,6 +347,67 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return cloneUser(updated), nil } +func (s *InternalStore) TagUser(_ context.Context, userName string, tags []types.Tag) error { + return s.updateUserTags(userName, func(user *types.User) error { + merged, err := mergeTags(user.Tags, tags) + if err != nil { + return err + } + user.Tags = merged + return nil + }) +} + +func (s *InternalStore) UntagUser(_ context.Context, userName string, tagKeys []string) error { + return s.updateUserTags(userName, func(user *types.User) error { + user.Tags = removeTags(user.Tags, tagKeys) + return nil + }) +} + +// updateUserTags applies mutate to userName's stored record and writes it +// back under the store lock. +func (s *InternalStore) updateUserTags(userName string, mutate func(*types.User) error) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, user, ok := lookupUser(conf, userName) + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + if err := mutate(&user); err != nil { + return nil, err + } + + conf.Users[canonical] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, user, ok := lookupUser(conf, input.UserName) + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + return paginateTags(user.Tags, input), nil +} + func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { s.Lock() defer s.Unlock() diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 28aa5a8d..c5be9414 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -16,167 +16,13 @@ package storage import ( "context" - "errors" "fmt" "strings" "time" - "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" ) -// MaxAccessKeysPerUser is the maximum number of access keys a single IAM -// user may hold at once, matching the AWS IAM quota. -const MaxAccessKeysPerUser = 2 - -// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of -// all of a single IAM user's inline policy documents combined -const MaxInlinePolicyBytesPerUser = 2048 - -// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of -// all of a single IAM role's inline policy documents combined -const MaxInlinePolicyBytesPerRole = 10240 - -// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single -// OIDC provider may hold at once -const MaxClientIDsPerOIDCProvider = 100 - -// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a -// single account may hold -const MaxOIDCProvidersPerAccount = 100 - -// MaxActiveSessionsPerRole bounds how many currently-unexpired -// AssumeRoleWithWebIdentity sessions a single role may have at once. -// AWS manages and rate-limits STS as a hosted service with no -// customer-visible equivalent quota to match for fidelity; this exists -// purely as local resource protection, since without it a single valid -// federated token can be replayed indefinitely to grow the session -// store — every InternalStore rewrite, or Vault KV path/metadata entry — -// without bound. Chosen generously enough to not constrain any legitimate -// workload's concurrent session count. -// -// A var, not a const, so tests can temporarily lower it rather than paying -// the cost of actually creating 1000 sessions to exercise the cap. -var MaxActiveSessionsPerRole = 1000 - -var ( - ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") - 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 { - PathPrefix string - Marker string - MaxItems int32 -} - -type ListUsersOutput struct { - Users []types.User - IsTruncated bool - Marker string -} - -type UpdateUserInput struct { - UserName string - NewPath string - NewUserName string - NewArn string -} - -type CreateAccessKeyInput struct { - UserName string - AccessKeyID string - SecretAccessKey string - Status string - CreateDate time.Time -} - -type UpdateAccessKeyInput struct { - UserName string - AccessKeyID string - Status string -} - -type ListAccessKeysInput struct { - UserName string - Marker string - MaxItems int32 -} - -type ListAccessKeysOutput struct { - AccessKeys []types.AccessKeyMetadata - IsTruncated bool - Marker string -} - -type GetAccessKeyLastUsedOutput struct { - UserName string - LastUsedDate time.Time - ServiceName string - Region string -} - -type PutUserPolicyInput struct { - UserName string - PolicyName string - PolicyDocument string -} - -type ListUserPoliciesInput struct { - UserName string - Marker string - MaxItems int32 -} - -type ListUserPoliciesOutput struct { - PolicyNames []string - IsTruncated bool - Marker string -} - -type ListRolesInput struct { - PathPrefix string - Marker string - MaxItems int32 -} - -type ListRolesOutput struct { - Roles []types.Role - IsTruncated bool - Marker string -} - -type UpdateAssumeRolePolicyInput struct { - RoleName string - PolicyDocument string -} - -type PutRolePolicyInput struct { - RoleName string - PolicyName string - PolicyDocument string -} - -type ListRolePoliciesInput struct { - RoleName string - Marker string - MaxItems int32 -} - -type ListRolePoliciesOutput struct { - PolicyNames []string - IsTruncated bool - Marker string -} - -type ListOIDCProvidersOutput struct { - Providers []types.OpenIDConnectProviderListEntry -} - // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -186,6 +32,10 @@ type Storer interface { ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) + TagUser(ctx context.Context, userName string, tags []types.Tag) error + UntagUser(ctx context.Context, userName string, tagKeys []string) error + ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListUserTagsOutput, 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 @@ -227,15 +77,6 @@ type Storer interface { GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) } -func unwrapAPIError(err error) error { - var apiErr iamerr.APIError - if errors.As(err, &apiErr) { - return apiErr - } - - return err -} - type Config struct { Dir string Vault VaultConfig diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index e5d10da1..2a56dacc 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -643,6 +643,35 @@ func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate fu return nil, iamerr.ConcurrentModification() } +func (s *VaultStore) TagUser(ctx context.Context, userName string, tags []types.Tag) error { + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + merged, err := mergeTags(user.Tags, tags) + if err != nil { + return err + } + user.Tags = merged + return nil + }) + return err +} + +func (s *VaultStore) UntagUser(ctx context.Context, userName string, tagKeys []string) error { + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + user.Tags = removeTags(user.Tags, tagKeys) + return nil + }) + return err +} + +func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + return paginateTags(user.Tags, input), nil +} + func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { var created types.AccessKey if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { diff --git a/iamapi/types/user.go b/iamapi/types/user.go index f8f4a91f..76dca53f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -113,3 +113,44 @@ type Tag struct { Key string Value string } + +// Tags is the XML wrapper for a tag list, rendering as +// — and, when empty, as rather +// than being omitted, matching AWS's response for an untagged user. +type Tags struct { + Members []Tag `xml:"member"` +} + +type TagUserResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagUserResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *TagUserResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UntagUserResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagUserResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UntagUserResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserTagsResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserTagsResponse"` + Result ListUserTagsResult `xml:"ListUserTagsResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListUserTagsResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserTagsResult struct { + Tags Tags + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 401a5ccd..efbbe55a 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1192,6 +1192,51 @@ func TestIAMUpdateUser(ts *TestState) { ts.Run(IAMUpdateUser_success) } +func TestIAMTagUser(ts *TestState) { + ts.Run(IAMTagUser_missing_user_name) + ts.Run(IAMTagUser_invalid_user_name) + ts.Run(IAMTagUser_user_name_too_long) + ts.Run(IAMTagUser_missing_tags) + ts.Run(IAMTagUser_missing_tag_key) + ts.Run(IAMTagUser_missing_tag_value) + ts.Run(IAMTagUser_empty_tag_key) + ts.Run(IAMTagUser_tag_key_too_long) + ts.Run(IAMTagUser_invalid_tag_key) + ts.Run(IAMTagUser_tag_value_too_long) + ts.Run(IAMTagUser_invalid_tag_value) + ts.Run(IAMTagUser_duplicate_tag_keys) + ts.Run(IAMTagUser_too_many_tags) + ts.Run(IAMTagUser_non_existing_user) + ts.Run(IAMTagUser_tag_limit_exceeded) + ts.Run(IAMTagUser_success) + ts.Run(IAMTagUser_overwrites_existing_tag) +} + +func TestIAMUntagUser(ts *TestState) { + ts.Run(IAMUntagUser_missing_user_name) + ts.Run(IAMUntagUser_invalid_user_name) + ts.Run(IAMUntagUser_user_name_too_long) + ts.Run(IAMUntagUser_missing_tag_keys) + ts.Run(IAMUntagUser_invalid_tag_key) + ts.Run(IAMUntagUser_too_many_tag_keys) + ts.Run(IAMUntagUser_non_existing_user) + ts.Run(IAMUntagUser_success) + ts.Run(IAMUntagUser_removal_is_idempotent) + ts.Run(IAMUntagUser_case_insensitive_key) +} + +func TestIAMListUserTags(ts *TestState) { + ts.Run(IAMListUserTags_missing_user_name) + ts.Run(IAMListUserTags_invalid_user_name) + ts.Run(IAMListUserTags_user_name_too_long) + ts.Run(IAMListUserTags_invalid_max_items) + ts.Run(IAMListUserTags_invalid_max_items_format) + ts.Run(IAMListUserTags_non_existing_user) + ts.Run(IAMListUserTags_empty_result) + ts.Run(IAMListUserTags_success) + ts.Run(IAMListUserTags_pagination) +} + func TestIAMCreateAccessKey(ts *TestState) { ts.Run(IAMCreateAccessKey_missing_user_name) ts.Run(IAMCreateAccessKey_invalid_user_name) @@ -1633,6 +1678,9 @@ func TestIAM(ts *TestState) { TestIAMListUsers(ts) TestIAMDeleteUser(ts) TestIAMUpdateUser(ts) + TestIAMTagUser(ts) + TestIAMUntagUser(ts) + TestIAMListUserTags(ts) TestIAMCreateAccessKey(ts) TestIAMUpdateAccessKey(ts) TestIAMDeleteAccessKey(ts) @@ -2123,6 +2171,42 @@ 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, + "IAMTagUser_missing_user_name": IAMTagUser_missing_user_name, + "IAMTagUser_invalid_user_name": IAMTagUser_invalid_user_name, + "IAMTagUser_user_name_too_long": IAMTagUser_user_name_too_long, + "IAMTagUser_missing_tags": IAMTagUser_missing_tags, + "IAMTagUser_missing_tag_key": IAMTagUser_missing_tag_key, + "IAMTagUser_missing_tag_value": IAMTagUser_missing_tag_value, + "IAMTagUser_empty_tag_key": IAMTagUser_empty_tag_key, + "IAMTagUser_tag_key_too_long": IAMTagUser_tag_key_too_long, + "IAMTagUser_invalid_tag_key": IAMTagUser_invalid_tag_key, + "IAMTagUser_tag_value_too_long": IAMTagUser_tag_value_too_long, + "IAMTagUser_invalid_tag_value": IAMTagUser_invalid_tag_value, + "IAMTagUser_duplicate_tag_keys": IAMTagUser_duplicate_tag_keys, + "IAMTagUser_too_many_tags": IAMTagUser_too_many_tags, + "IAMTagUser_non_existing_user": IAMTagUser_non_existing_user, + "IAMTagUser_tag_limit_exceeded": IAMTagUser_tag_limit_exceeded, + "IAMTagUser_success": IAMTagUser_success, + "IAMTagUser_overwrites_existing_tag": IAMTagUser_overwrites_existing_tag, + "IAMUntagUser_missing_user_name": IAMUntagUser_missing_user_name, + "IAMUntagUser_invalid_user_name": IAMUntagUser_invalid_user_name, + "IAMUntagUser_user_name_too_long": IAMUntagUser_user_name_too_long, + "IAMUntagUser_missing_tag_keys": IAMUntagUser_missing_tag_keys, + "IAMUntagUser_invalid_tag_key": IAMUntagUser_invalid_tag_key, + "IAMUntagUser_too_many_tag_keys": IAMUntagUser_too_many_tag_keys, + "IAMUntagUser_non_existing_user": IAMUntagUser_non_existing_user, + "IAMUntagUser_success": IAMUntagUser_success, + "IAMUntagUser_removal_is_idempotent": IAMUntagUser_removal_is_idempotent, + "IAMUntagUser_case_insensitive_key": IAMUntagUser_case_insensitive_key, + "IAMListUserTags_missing_user_name": IAMListUserTags_missing_user_name, + "IAMListUserTags_invalid_user_name": IAMListUserTags_invalid_user_name, + "IAMListUserTags_user_name_too_long": IAMListUserTags_user_name_too_long, + "IAMListUserTags_invalid_max_items": IAMListUserTags_invalid_max_items, + "IAMListUserTags_invalid_max_items_format": IAMListUserTags_invalid_max_items_format, + "IAMListUserTags_non_existing_user": IAMListUserTags_non_existing_user, + "IAMListUserTags_empty_result": IAMListUserTags_empty_result, + "IAMListUserTags_success": IAMListUserTags_success, + "IAMListUserTags_pagination": IAMListUserTags_pagination, "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, diff --git a/tests/integration/iam_list_access_keys.go b/tests/integration/iam_list_access_keys.go index 87efa59b..505158a0 100644 --- a/tests/integration/iam_list_access_keys.go +++ b/tests/integration/iam_list_access_keys.go @@ -63,12 +63,15 @@ 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} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, 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) } @@ -94,8 +97,7 @@ func IAMListAccessKeys_invalid_max_items_format(s *S3Conf) error { 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) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_list_role_policies.go b/tests/integration/iam_list_role_policies.go index bbc68da8..c58aa210 100644 --- a/tests/integration/iam_list_role_policies.go +++ b/tests/integration/iam_list_role_policies.go @@ -70,7 +70,7 @@ func IAMListRolePolicies_invalid_max_items(s *S3Conf) error { _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)}) return err }(), - iamerr.InvalidMaxItems("1001"), + iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), ) deleteErr := deleteIAMRole(client, roleName) diff --git a/tests/integration/iam_list_roles.go b/tests/integration/iam_list_roles.go index 0849a288..16184bf2 100644 --- a/tests/integration/iam_list_roles.go +++ b/tests/integration/iam_list_roles.go @@ -58,9 +58,12 @@ func IAMListRoles_long_path_prefix(s *S3Conf) error { 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} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, 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) } @@ -85,8 +88,7 @@ func IAMListRoles_invalid_max_items_format(s *S3Conf) error { 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) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_list_user_policies.go b/tests/integration/iam_list_user_policies.go index c9514ef2..e53c2351 100644 --- a/tests/integration/iam_list_user_policies.go +++ b/tests/integration/iam_list_user_policies.go @@ -67,7 +67,7 @@ func IAMListUserPolicies_invalid_max_items(s *S3Conf) error { _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)}) return err }(), - iamerr.InvalidMaxItems("1001"), + iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), ) deleteErr := deleteIAMUser(client, userName) diff --git a/tests/integration/iam_list_user_tags.go b/tests/integration/iam_list_user_tags.go new file mode 100644 index 00000000..e48d9b6d --- /dev/null +++ b/tests/integration/iam_list_user_tags.go @@ -0,0 +1,261 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES 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/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 IAMListUserTags_missing_user_name(s *S3Conf) error { + testName := "IAMListUserTags_missing_user_name" + body := []byte(url.Values{ + "Action": {"ListUserTags"}, + "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("userName")) + }) +} + +func IAMListUserTags_invalid_user_name(s *S3Conf) error { + testName := "IAMListUserTags_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String("invalid user name"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMListUserTags_user_name_too_long(s *S3Conf) error { + testName := "IAMListUserTags_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMListUserTags_invalid_max_items(s *S3Conf) error { + testName := "IAMListUserTags_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String("validusername"), + MaxItems: aws.Int32(maxItems), + }) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListUserTags_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListUserTags_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListUserTags"}, + "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 { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMListUserTags_non_existing_user(s *S3Conf) error { + testName := "IAMListUserTags_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListUserTags_empty_result(s *S3Conf) error { + testName := "IAMListUserTags_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 := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %v", iamTagMap(out.Tags)) + } + 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 IAMListUserTags_success(s *S3Conf) error { + testName := "IAMListUserTags_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"created": "at-create-time"}), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}), + }); err != nil { + return err + } + + out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListUserTags response request id") + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + // Tags supplied at creation and tags added afterwards are the + // same set: TagUser merges into whatever CreateUser stored. + return compareIAMTags(out.Tags, map[string]string{ + "created": "at-create-time", + "env": "prod", + "team": "storage", + }) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserTags_pagination(s *S3Conf) error { + testName := "IAMListUserTags_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"} + tags := map[string]string{} + for _, key := range want { + tags[key] = key + "-value" + } + if _, err := tagIAMUser(client, &iam.TagUserInput{UserName: &userName, Tags: iamTagList(tags)}); err != nil { + return err + } + + input := iam.ListUserTagsInput{UserName: &userName, MaxItems: aws.Int32(1)} + var pages []*iam.ListUserTagsOutput + for { + out, err := listIAMUserTags(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.Tags) != 1 { + return fmt.Errorf("expected page %d to contain 1 tag, instead got %d", i+1, len(page.Tags)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, aws.ToString(page.Tags[0].Key)) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected tag keys %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMUserTags(client *iam.Client, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListUserTags(ctx, input) +} diff --git a/tests/integration/iam_list_users.go b/tests/integration/iam_list_users.go index 7da6b266..555792c7 100644 --- a/tests/integration/iam_list_users.go +++ b/tests/integration/iam_list_users.go @@ -57,9 +57,12 @@ func IAMListUsers_long_path_prefix(s *S3Conf) error { func IAMListUsers_invalid_max_items(s *S3Conf) error { testName := "IAMListUsers_invalid_max_items" return iamActionHandler(s, testName, func(client *iam.Client) error { - for _, maxItems := range []int32{-1, 0, 1001} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, err := listIAMUsers(client, &iam.ListUsersInput{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) } @@ -84,8 +87,7 @@ func IAMListUsers_invalid_max_items_format(s *S3Conf) error { 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) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_tag_user.go b/tests/integration/iam_tag_user.go new file mode 100644 index 00000000..23287144 --- /dev/null +++ b/tests/integration/iam_tag_user.go @@ -0,0 +1,427 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES 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" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMTagUser_missing_user_name(s *S3Conf) error { + testName := "IAMTagUser_missing_user_name" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }.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 IAMTagUser_invalid_user_name(s *S3Conf) error { + testName := "IAMTagUser_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("invalid user name"), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMTagUser_user_name_too_long(s *S3Conf) error { + testName := "IAMTagUser_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String(strings.Repeat("a", 129)), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMTagUser_missing_tags(s *S3Conf) error { + testName := "IAMTagUser_missing_tags" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "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.MissingValue("tags")) + }) +} + +func IAMTagUser_missing_tag_key(s *S3Conf) error { + testName := "IAMTagUser_missing_tag_key" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Tags.member.1.Value": {"prod"}, + }.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.MissingTagKey(1)) + }) +} + +func IAMTagUser_missing_tag_value(s *S3Conf) error { + testName := "IAMTagUser_missing_tag_value" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Tags.member.1.Key": {"env"}, + }.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.MissingTagValue(1)) + }) +} + +func IAMTagUser_empty_tag_key(s *S3Conf) error { + testName := "IAMTagUser_empty_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}}, + }) + return checkIAMApiErr(err, iamerr.TagKeyTooShort(1)) + }) +} + +func IAMTagUser_tag_key_too_long(s *S3Conf) error { + testName := "IAMTagUser_tag_key_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}), + }) + return checkIAMApiErr(err, iamerr.TagKeyTooLong(1)) + }) +} + +func IAMTagUser_invalid_tag_key(s *S3Conf) error { + testName := "IAMTagUser_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"invalid*key": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagKey(1)) + }) +} + +func IAMTagUser_tag_value_too_long(s *S3Conf) error { + testName := "IAMTagUser_tag_value_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"env": strings.Repeat("v", 257)}), + }) + return checkIAMApiErr(err, iamerr.TagValueTooLong(1)) + }) +} + +func IAMTagUser_invalid_tag_value(s *S3Conf) error { + testName := "IAMTagUser_invalid_tag_value" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"env": "invalid*value"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagValue(1)) + }) +} + +// IAMTagUser_duplicate_tag_keys covers both an exact repeat and a +// differently-cased repeat: IAM compares tag keys case-insensitively, so +// both are the same key twice in one request. +func IAMTagUser_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMTagUser_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, second := range []string{"env", "ENV"} { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String(second), Value: aws.String("staging")}, + }, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)); checkErr != nil { + return fmt.Errorf("duplicate key %q: %w", second, checkErr) + } + } + return nil + }) +} + +func IAMTagUser_too_many_tags(s *S3Conf) error { + testName := "IAMTagUser_too_many_tags" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags)) + }) +} + +func IAMTagUser_non_existing_user(s *S3Conf) error { + testName := "IAMTagUser_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMTagUser_tag_limit_exceeded(s *S3Conf) error { + testName := "IAMTagUser_tag_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 { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: numberedIAMTags(1, storage.MaxTagsPerUser), + }); err != nil { + return err + } + + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"key1": "replaced"}), + }); err != nil { + return fmt.Errorf("replacing a tag at the quota: %w", err) + } + + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"overflow": "x"}), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMTagUser_success(s *S3Conf) error { + testName := "IAMTagUser_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 { + // An empty tag value is legal; only the key has a minimum length. + want := map[string]string{"env": "prod", "team": "storage", "empty": ""} + out, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(want), + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected TagUser response request id") + } + + if err := checkIAMUserTags(client, userName, want); err != nil { + return err + } + // GetUser reports the same tags the tag actions maintain. + user, err := getIAMUser(client, &iam.GetUserInput{UserName: &userName}) + if err != nil { + return err + } + return compareIAMTags(user.User.Tags, want) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagUser_overwrites_existing_tag covers re-tagging a key that is +// already present: the value is replaced rather than added alongside, and a +// differently-cased key is the same tag — the newly supplied casing wins. +func IAMTagUser_overwrites_existing_tag(s *S3Conf) error { + testName := "IAMTagUser_overwrites_existing_tag" + 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 := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "staging"}), + }); err != nil { + return err + } + if err := checkIAMUserTags(client, userName, map[string]string{"env": "staging"}); err != nil { + return err + } + + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"ENV": "qa"}), + }); err != nil { + return err + } + return checkIAMUserTags(client, userName, map[string]string{"ENV": "qa"}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// maxIAMTagMembersPerRequest mirrors the per-request Tags/TagKeys member +// cap the service enforces +const maxIAMTagMembersPerRequest = 50 + +func tagIAMUser(client *iam.Client, input *iam.TagUserInput) (*iam.TagUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.TagUser(ctx, input) +} + +func iamTagList(tags map[string]string) []iamtypes.Tag { + list := make([]iamtypes.Tag, 0, len(tags)) + for key, value := range tags { + list = append(list, iamtypes.Tag{Key: aws.String(key), Value: aws.String(value)}) + } + return list +} + +// numberedIAMTags builds count tags named key/value starting at +// first, for exercising the per-request and per-user tag quotas. +func numberedIAMTags(first, count int) []iamtypes.Tag { + list := make([]iamtypes.Tag, 0, count) + for i := first; i < first+count; i++ { + list = append(list, iamtypes.Tag{ + Key: aws.String(fmt.Sprintf("key%d", i)), + Value: aws.String(fmt.Sprintf("value%d", i)), + }) + } + return list +} + +// checkIAMUserTags asserts ListUserTags reports exactly want for userName. +func checkIAMUserTags(client *iam.Client, userName string, want map[string]string) error { + out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return compareIAMTags(out.Tags, want) +} + +func compareIAMTags(got []iamtypes.Tag, want map[string]string) error { + if len(got) != len(want) { + return fmt.Errorf("expected %d tags, instead got %d: %v", len(want), len(got), iamTagMap(got)) + } + for key, value := range want { + found, ok := iamTagMap(got)[key] + if !ok { + return fmt.Errorf("expected tag %q to be present, instead got %v", key, iamTagMap(got)) + } + if found != value { + return fmt.Errorf("expected tag %q to be %q, instead got %q", key, value, found) + } + } + return nil +} + +func iamTagMap(tags []iamtypes.Tag) map[string]string { + out := make(map[string]string, len(tags)) + for _, tag := range tags { + out[aws.ToString(tag.Key)] = aws.ToString(tag.Value) + } + return out +} diff --git a/tests/integration/iam_untag_user.go b/tests/integration/iam_untag_user.go new file mode 100644 index 00000000..e5dd216b --- /dev/null +++ b/tests/integration/iam_untag_user.go @@ -0,0 +1,265 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES 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" +) + +func IAMUntagUser_missing_user_name(s *S3Conf) error { + testName := "IAMUntagUser_missing_user_name" + body := []byte(url.Values{ + "Action": {"UntagUser"}, + "Version": {"2010-05-08"}, + "TagKeys.member.1": {"env"}, + }.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 IAMUntagUser_invalid_user_name(s *S3Conf) error { + testName := "IAMUntagUser_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("invalid user name"), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMUntagUser_user_name_too_long(s *S3Conf) error { + testName := "IAMUntagUser_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String(strings.Repeat("a", 129)), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMUntagUser_missing_tag_keys(s *S3Conf) error { + testName := "IAMUntagUser_missing_tag_keys" + body := []byte(url.Values{ + "Action": {"UntagUser"}, + "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.MissingValue("tagKeys")) + }) +} + +// IAMUntagUser_invalid_tag_key covers every malformed TagKeys member the +// same way IAM does: whichever constraint fails — empty, over-long, or +// outside the allowed charset — the response is one generic error naming +// the whole constraint set, not the individual constraint that tripped. +func IAMUntagUser_invalid_tag_key(s *S3Conf) error { + testName := "IAMUntagUser_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("validusername"), + TagKeys: []string{tagKey}, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)); checkErr != nil { + return fmt.Errorf("tag key %q: %w", tagKey, checkErr) + } + } + return nil + }) +} + +func IAMUntagUser_too_many_tag_keys(s *S3Conf) error { + testName := "IAMUntagUser_too_many_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tagKeys := make([]string, 0, maxIAMTagMembersPerRequest+1) + for i := range maxIAMTagMembersPerRequest + 1 { + tagKeys = append(tagKeys, fmt.Sprintf("key%d", i+1)) + } + + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("validusername"), + TagKeys: tagKeys, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys)) + }) +} + +func IAMUntagUser_non_existing_user(s *S3Conf) error { + testName := "IAMUntagUser_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMUntagUser_success(s *S3Conf) error { + testName := "IAMUntagUser_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 := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}), + }); err != nil { + return err + } + + out, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env", "owner"}, + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UntagUser response request id") + } + + return checkIAMUserTags(client, userName, map[string]string{"team": "storage"}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagUser_removal_is_idempotent covers the two ways a request can name +// a key that removes nothing: a key the user never carried, and the same +// key twice in one request. Neither is an error — unlike TagUser, which +// rejects a repeated key outright. +func IAMUntagUser_removal_is_idempotent(s *S3Conf) error { + testName := "IAMUntagUser_removal_is_idempotent" + 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 := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"never-existed"}, + }); err != nil { + return fmt.Errorf("removing a key the user does not carry: %w", err) + } + if err := checkIAMUserTags(client, userName, map[string]string{"env": "prod"}); err != nil { + return err + } + + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env", "env"}, + }); err != nil { + return fmt.Errorf("removing the same key twice: %w", err) + } + return checkIAMUserTags(client, userName, map[string]string{}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagUser_case_insensitive_key covers removal by a differently-cased +// key: IAM compares tag keys case-insensitively, so the tag is removed even +// though the supplied key does not match the stored casing. +func IAMUntagUser_case_insensitive_key(s *S3Conf) error { + testName := "IAMUntagUser_case_insensitive_key" + 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 := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"EnV"}, + }); err != nil { + return err + } + return checkIAMUserTags(client, userName, map[string]string{}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func untagIAMUser(client *iam.Client, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UntagUser(ctx, input) +} diff --git a/webui/web/iam-users.html b/webui/web/iam-users.html index 8520ea58..8f9af31a 100644 --- a/webui/web/iam-users.html +++ b/webui/web/iam-users.html @@ -211,7 +211,7 @@ under the License.
-

Tags are set at creation only. This API has no tag-update action, so they are read-only afterwards.

+

Optional. Tags can also be added, changed and removed later from the user’s Manage view.

@@ -275,13 +275,21 @@ under the License.
Created
-
-
-
Tags
-
-
-
+ +
+
+
+

Tags

+

Key/value labels, also readable from policy conditions.

+
+ +
+
+
+
@@ -451,6 +459,7 @@ under the License. let nextMarker = null; let currentUser = null; // the user open in the manage modal let policySizes = {}; // policyName -> byte length, for the aggregate quota + let currentTags = []; // the open user's tags, as [{Key, Value}] let activeKeyCount = 0; let userToDelete = null; // The generated secret lives here and nowhere else: never sessionStorage, @@ -642,6 +651,7 @@ under the License. } } policySizes = {}; + currentTags = []; activeKeyCount = 0; document.getElementById('manage-user-title').textContent = userName; @@ -650,23 +660,73 @@ under the License. document.getElementById('detail-userid').textContent = currentUser.UserId || '-'; document.getElementById('detail-path').textContent = currentUser.Path || '/'; document.getElementById('detail-created').textContent = iamFormatDate(currentUser.CreateDate); - renderTags(currentUser.Tags); openModal('manage-user-modal'); + loadUserTags(); loadAccessKeys(); loadUserPolicies(); } - function renderTags(tags) { - const el = document.getElementById('detail-tags'); - const list = Array.isArray(tags) ? tags : (tags ? [tags] : []); - if (list.length === 0) { - el.innerHTML = '-'; - return; + // ============================================ + // Manage: tags + // ============================================ + + /** + * ListUserTags is its own permission, so this loads the tags rather than + * reusing whatever GetUser happened to return — and a denial disables + * editing in place instead of failing the whole modal. + */ + async function loadUserTags() { + const el = document.getElementById('user-tags'); + el.innerHTML = 'Loading...'; + try { + currentTags = []; + let marker = null; + do { + const page = await api.iamListUserTags(currentUser.UserName, { marker: marker || undefined }); + currentTags = currentTags.concat(page.tags); + marker = page.isTruncated ? page.marker : null; + } while (marker); + + el.innerHTML = iamTagChips(currentTags); + setEditTagsEnabled(true); + updateTagQuotaNote(); + } catch (error) { + console.error('Error loading tags:', error); + setEditTagsEnabled(false); + el.innerHTML = iamIsAccessDenied(error) + ? 'You don\u2019t have permission to list this user\u2019s tags' + : `Error loading tags: ${escapeHtml(iamShortError(error))}`; } - el.innerHTML = list.map(tag => - `${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}` - ).join(''); + } + + function setEditTagsEnabled(enabled) { + const button = document.getElementById('edit-tags-btn'); + button.disabled = !enabled; + button.className = enabled + ? 'px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors' + : 'px-3 py-1.5 text-xs border border-gray-200 text-charcoal-300 rounded-lg opacity-50 cursor-not-allowed'; + } + + function updateTagQuotaNote() { + document.getElementById('tag-quota-note').textContent = + `${currentTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Also readable from policy conditions.`; + } + + function openUserTagEditor() { + iamTagEditor.open({ + title: 'Edit Tags', + subtitle: `User ${currentUser.UserName}`, + tags: currentTags, + onSave: async ({ set, remove }) => { + // Removals first: they free room under the 50-tag cap for whatever + // this same edit is adding. + if (remove.length) await api.iamUntagUser(currentUser.UserName, remove); + if (set.length) await api.iamTagUser(currentUser.UserName, set); + showToast('Tags updated successfully', 'success'); + loadUserTags(); + } + }); } async function loadAccessKeys() { diff --git a/webui/web/js/api.js b/webui/web/js/api.js index 1d52c8d3..c60bc67d 100644 --- a/webui/web/js/api.js +++ b/webui/web/js/api.js @@ -2493,6 +2493,36 @@ ${tagsXml} await this.iamRequest('DeleteUser', { UserName: userName }); } + // ---- User tags ---- + + /** + * Add or replace tags on a user. A key already present is overwritten + * rather than duplicated, so this doubles as the edit path. + */ + async iamTagUser(userName, tags) { + const params = { UserName: userName }; + this.flattenTags(params, tags); + await this.iamRequest('TagUser', params); + } + + async iamUntagUser(userName, tagKeys) { + const params = { UserName: userName }; + this.flattenMemberList(params, 'TagKeys', tagKeys); + await this.iamRequest('UntagUser', params); + } + + async iamListUserTags(userName, options = {}) { + const params = { UserName: userName }; + if (options.marker) params.Marker = options.marker; + if (options.maxItems) params.MaxItems = options.maxItems; + const result = await this.iamRequest('ListUserTags', params); + return { + tags: iamAsArray(result.Tags), + isTruncated: result.IsTruncated === 'true', + marker: result.Marker || null + }; + } + // ---- Access keys ---- /** diff --git a/webui/web/js/iam-ui.js b/webui/web/js/iam-ui.js index 7e6dfaa9..9423a9b1 100644 --- a/webui/web/js/iam-ui.js +++ b/webui/web/js/iam-ui.js @@ -38,6 +38,8 @@ const IAM_LIMITS = { tagsPerResource: 50, tagKeyChars: 128, tagValueChars: 256, + tagKeyPattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u, + tagValuePattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u, minSessionDuration: 3600, maxSessionDuration: 43200, oidcClientIds: 100, @@ -164,6 +166,56 @@ function iamValidatePath(path) { return null; } +/** + * Validate a collected tag list against the same rules the server applies, + * so an obvious mistake is caught before a round trip. Returns an error + * string, or null when the list is acceptable. + */ +function iamValidateTags(tags) { + if (tags.length > IAM_LIMITS.tagsPerResource) { + return `A user can carry ${IAM_LIMITS.tagsPerResource} tags at most.`; + } + + const seen = new Set(); + for (const tag of tags) { + if (!tag.Key) return 'Every tag needs a key.'; + if (tag.Key.length > IAM_LIMITS.tagKeyChars) { + return `Tag key "${tag.Key}" must be ${IAM_LIMITS.tagKeyChars} characters or fewer.`; + } + if (!IAM_LIMITS.tagKeyPattern.test(tag.Key)) { + return `Tag key "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`; + } + if ((tag.Value || '').length > IAM_LIMITS.tagValueChars) { + return `Tag value for "${tag.Key}" must be ${IAM_LIMITS.tagValueChars} characters or fewer.`; + } + if (!IAM_LIMITS.tagValuePattern.test(tag.Value || '')) { + return `Tag value for "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`; + } + // Tag keys are compared case-insensitively, so "env" and "ENV" are the + // same key twice — which the service rejects outright. + const folded = tag.Key.toLowerCase(); + if (seen.has(folded)) return `Tag key "${tag.Key}" is listed twice. Keys are case insensitive.`; + seen.add(folded); + } + + return null; +} + +/** + * Render a tag list as read-only two-tone chips, key alongside value. + */ +function iamTagChips(tags) { + if (!tags.length) return 'No tags'; + return tags.map(tag => { + const value = tag.Value + ? `${escapeHtml(tag.Value)}` + : 'empty'; + return ` + ${escapeHtml(tag.Key)}${value} + `; + }).join(''); +} + // ============================================ // Repeatable form rows (tags, client IDs, thumbprints) // ============================================ @@ -639,3 +691,186 @@ const iamPolicyEditor = { } } }; + +// ============================================ +// Tag editor +// ============================================ + +/** + * Edit a user's whole tag set at once, then apply it as the minimal pair of + * API calls: one UntagUser for the keys that disappeared, one TagUser for + * the ones added or changed. Editing the set as a whole — rather than a + * tag at a time — is what lets a rename, a couple of additions and a couple + * of removals be one reviewable Save. + */ +const iamTagEditor = { + _state: null, + + _ensureModal() { + if (document.getElementById('iam-tag-modal')) return; + const wrapper = document.createElement('div'); + wrapper.id = 'iam-tag-modal'; + wrapper.className = 'modal hidden fixed inset-0 z-50'; + wrapper.innerHTML = ` +
+
+
+
+

Tags

+

+
+ +
+
+
+
+
+ +
+

About Tags

+

Key/value labels for grouping and search. They are also readable from policy conditions as aws:PrincipalTag/<key> for the tagged user and aws:ResourceTag/<key> for the user being acted on. Keys are case insensitive; values may be empty.

+
+
+
+ +
+
+ + +
+
+ +

+
+
+
+
+ + +
+
+
`; + document.body.appendChild(wrapper); + + // iamAddTagRow and iamRemoveRow are shared with the create forms and + // report nothing back, so watch the container instead of hooking them. + new MutationObserver(() => this.updateCounter()) + .observe(document.getElementById('iam-tag-rows'), { childList: true }); + }, + + /** + * @param {Object} opts + * title modal heading + * subtitle the identity these tags belong to + * tags current tags, as [{Key, Value}] + * onSave async ({ set, remove }) => void, where set holds the tags to + * add or overwrite and remove holds the keys to drop + */ + open(opts) { + this._ensureModal(); + this._state = Object.assign({}, opts); + + document.getElementById('iam-tag-title').textContent = opts.title || 'Tags'; + document.getElementById('iam-tag-subtitle').textContent = opts.subtitle || ''; + + const rows = document.getElementById('iam-tag-rows'); + rows.innerHTML = ''; + (opts.tags || []).forEach(tag => iamAddTagRow('iam-tag-rows', tag.Key, tag.Value || '')); + + this._setStatus(null); + this.updateCounter(); + openModal('iam-tag-modal'); + }, + + close() { + this._state = null; + closeModal('iam-tag-modal'); + }, + + addRow() { + iamAddTagRow('iam-tag-rows'); + const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]'); + const last = rows[rows.length - 1]; + if (last) last.querySelector('[data-tag-key]').focus(); + }, + + updateCounter() { + const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]').length; + document.getElementById('iam-tag-empty').classList.toggle('hidden', rows > 0); + + const counter = document.getElementById('iam-tag-counter'); + const over = rows > IAM_LIMITS.tagsPerResource; + counter.className = 'mt-3 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300'); + counter.textContent = `${rows} / ${IAM_LIMITS.tagsPerResource} tags`; + }, + + _setStatus(message) { + const el = document.getElementById('iam-tag-status'); + if (!message) { + el.classList.add('hidden'); + el.textContent = ''; + return; + } + el.className = 'border rounded-lg px-4 py-3 text-sm bg-red-50 border-red-200 text-red-800'; + el.textContent = message; + el.classList.remove('hidden'); + }, + + /** + * Diff the edited rows against the tags the modal opened with. A key whose + * only change is its casing still lands in set: TagUser overwrites the + * stored tag in place, taking the new casing with it. + */ + _diff(current) { + const original = this._state.tags || []; + const originalByKey = new Map(original.map(tag => [tag.Key.toLowerCase(), tag])); + const currentKeys = new Set(current.map(tag => tag.Key.toLowerCase())); + + const set = current.filter(tag => { + const before = originalByKey.get(tag.Key.toLowerCase()); + return !before || before.Key !== tag.Key || (before.Value || '') !== (tag.Value || ''); + }); + const remove = original + .filter(tag => !currentKeys.has(tag.Key.toLowerCase())) + .map(tag => tag.Key); + + return { set, remove }; + }, + + async save() { + const state = this._state; + if (!state) return; + + // iamCollectTags drops keyless rows, so a value typed without a key + // would silently vanish. Catch that before it does. + const orphanValue = Array.from(document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]')) + .some(row => !row.querySelector('[data-tag-key]').value.trim() && + row.querySelector('[data-tag-value]').value.trim()); + if (orphanValue) { this._setStatus('Every tag needs a key.'); return; } + + const current = iamCollectTags('iam-tag-rows'); + const error = iamValidateTags(current); + if (error) { this._setStatus(error); return; } + + const { set, remove } = this._diff(current); + if (!set.length && !remove.length) { + showToast('No tag changes to save', 'info'); + this.close(); + return; + } + + const btn = document.getElementById('iam-tag-save-btn'); + setLoading(btn, true); + try { + await state.onSave({ set, remove }); + this.close(); + } catch (err) { + console.error('Error saving tags:', err); + this._setStatus(iamErrorText(err)); + } finally { + setLoading(btn, false); + } + } +};