mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 04:06:56 +00:00
Merge pull request #2314 from versity/sis/iam-user-tagging
feat: add IAM user tagging actions
This commit is contained in:
+256
-11
@@ -61,7 +61,7 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -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, iamutil.TagKeysFolded)
|
||||
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 {
|
||||
@@ -542,7 +624,7 @@ func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxUserNameLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -580,7 +662,7 @@ func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -626,7 +708,7 @@ func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) GetRole(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxRoleNameLen, iamerr.MissingParameter("RoleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -685,7 +767,7 @@ func (c IAMApiController) ListRoles(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) DeleteRole(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxRoleNameLen, iamerr.MissingParameter("RoleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -708,7 +790,7 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -737,6 +819,86 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro
|
||||
return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil
|
||||
}
|
||||
|
||||
// TagRole adds or replaces tags on an existing role.
|
||||
func (c IAMApiController) TagRole(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "TagRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
debuglogger.Logf("missing required TagRole parameter: Tags")
|
||||
return nil, iamerr.MissingValue("tags")
|
||||
}
|
||||
|
||||
if err := c.store.TagRole(ctx.Context(), roleName, tags); err != nil {
|
||||
debuglogger.Logf("failed to tag IAM role %q: %v", roleName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.TagRoleResponse{}}, nil
|
||||
}
|
||||
|
||||
// UntagRole removes the named tags from an existing role. Removal is
|
||||
// idempotent: a key naming no current tag is not an error.
|
||||
func (c IAMApiController) UntagRole(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "UntagRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
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 UntagRole parameter: TagKeys")
|
||||
return nil, iamerr.MissingValue("tagKeys")
|
||||
}
|
||||
|
||||
if err := c.store.UntagRole(ctx.Context(), roleName, tagKeys); err != nil {
|
||||
debuglogger.Logf("failed to untag IAM role %q: %v", roleName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.UntagRoleResponse{}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListRoleTags(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "ListRoleTags", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListRoleTags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
out, err := c.store.ListRoleTags(ctx.Context(), storage.ListRoleTagsInput{
|
||||
RoleName: roleName,
|
||||
Marker: marker,
|
||||
MaxItems: maxItems,
|
||||
})
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to list IAM role %q tags: %v", roleName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.ListRoleTagsResponse{
|
||||
Result: types.ListRoleTagsResult{
|
||||
Tags: types.Tags{Members: out.Tags},
|
||||
IsTruncated: out.IsTruncated,
|
||||
Marker: out.Marker,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument")
|
||||
if !ok {
|
||||
@@ -756,7 +918,7 @@ func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -793,7 +955,7 @@ func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -823,7 +985,7 @@ func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -837,7 +999,7 @@ func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) {
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) {
|
||||
roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
|
||||
roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -907,7 +1069,7 @@ func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response,
|
||||
thumbprints = iamutil.NormalizeThumbprintList(thumbprints)
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1052,6 +1214,89 @@ func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) (
|
||||
return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil
|
||||
}
|
||||
|
||||
// TagOpenIDConnectProvider adds or replaces tags on an existing OIDC
|
||||
// provider. Unlike the user and role tag actions, provider tag keys are
|
||||
// compared exactly, so "env" and "ENV" are two distinct tags.
|
||||
func (c IAMApiController) TagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "TagOpenIDConnectProvider")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
debuglogger.Logf("missing required TagOpenIDConnectProvider parameter: Tags")
|
||||
return nil, iamerr.MissingValue("tags")
|
||||
}
|
||||
|
||||
if err := c.store.TagOIDCProvider(ctx.Context(), arn, tags); err != nil {
|
||||
debuglogger.Logf("failed to tag IAM OIDC provider %q: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.TagOpenIDConnectProviderResponse{}}, nil
|
||||
}
|
||||
|
||||
// UntagOpenIDConnectProvider removes the named tags from an existing OIDC
|
||||
// provider. Removal is idempotent: a key naming no current tag is not an
|
||||
// error.
|
||||
func (c IAMApiController) UntagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "UntagOpenIDConnectProvider")
|
||||
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 UntagOpenIDConnectProvider parameter: TagKeys")
|
||||
return nil, iamerr.MissingValue("tagKeys")
|
||||
}
|
||||
|
||||
if err := c.store.UntagOIDCProvider(ctx.Context(), arn, tagKeys); err != nil {
|
||||
debuglogger.Logf("failed to untag IAM OIDC provider %q: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.UntagOpenIDConnectProviderResponse{}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListOpenIDConnectProviderTags(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "ListOpenIDConnectProviderTags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListOpenIDConnectProviderTags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
out, err := c.store.ListOIDCProviderTags(ctx.Context(), storage.ListOIDCProviderTagsInput{
|
||||
Arn: arn,
|
||||
Marker: marker,
|
||||
MaxItems: maxItems,
|
||||
})
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to list IAM OIDC provider %q tags: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.ListOpenIDConnectProviderTagsResponse{
|
||||
Result: types.ListOpenIDConnectProviderTagsResult{
|
||||
Tags: types.Tags{Members: out.Tags},
|
||||
IsTruncated: out.IsTruncated,
|
||||
Marker: out.Marker,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, error) {
|
||||
rawRoleArn, ok := iamutil.RequestParam(ctx, "RoleArn")
|
||||
if !ok || rawRoleArn == "" {
|
||||
|
||||
+1334
-8
File diff suppressed because it is too large
Load Diff
+61
-4
@@ -57,11 +57,17 @@ const (
|
||||
ErrInvalidContentLength
|
||||
ErrThrottling
|
||||
ErrTooManyTags
|
||||
ErrTooManyTagKeys
|
||||
ErrInvalidTagKeys
|
||||
ErrTagLimitExceeded
|
||||
ErrInvalidPathPrefix
|
||||
ErrDuplicateTagKeys
|
||||
ErrDuplicateExactTagKeys
|
||||
ErrInvalidAccessKeyIDChars
|
||||
ErrDeleteConflict
|
||||
ErrDeleteConflictPolicies
|
||||
ErrMaxItemsTooLow
|
||||
ErrMaxItemsTooHigh
|
||||
)
|
||||
|
||||
type APIError interface {
|
||||
@@ -221,12 +227,48 @@ 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",
|
||||
Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrDuplicateExactTagKeys: {
|
||||
Type: TypeSender,
|
||||
Code: "InvalidInput",
|
||||
Message: "Duplicate tag keys found.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrInvalidAccessKeyIDChars: {
|
||||
Type: TypeSender,
|
||||
Code: "ValidationError",
|
||||
@@ -405,10 +447,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 +467,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))
|
||||
}
|
||||
@@ -493,6 +547,9 @@ func NoSuchEntityOIDCProviderGet(arn string) Error {
|
||||
return newSenderError("NoSuchEntity", fmt.Sprintf("OpenIDConnect Provider not found for arn %s", arn), http.StatusNotFound)
|
||||
}
|
||||
|
||||
// NoSuchEntityOIDCProviderDelete is the wording DeleteOpenIDConnectProvider
|
||||
// and the provider tagging actions use, distinct from the one
|
||||
// NoSuchEntityOIDCProviderGet reports.
|
||||
func NoSuchEntityOIDCProviderDelete(arn string) Error {
|
||||
return newSenderError("NoSuchEntity", fmt.Sprintf("OpenId connect Provider %s cannot be found.", arn), http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -173,18 +173,21 @@ 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)
|
||||
case "CreateRole":
|
||||
return newRoleResource(ctx), nil
|
||||
case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies":
|
||||
case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies",
|
||||
"TagRole", "UntagRole", "ListRoleTags":
|
||||
return existingRoleResource(ctx, store)
|
||||
case "CreateOpenIDConnectProvider":
|
||||
return newOIDCProviderResource(ctx), nil
|
||||
case "GetOpenIDConnectProvider", "DeleteOpenIDConnectProvider", "AddClientIDToOpenIDConnectProvider",
|
||||
"RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint":
|
||||
"RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint",
|
||||
"TagOpenIDConnectProvider", "UntagOpenIDConnectProvider", "ListOpenIDConnectProviderTags":
|
||||
arn, _ := iamutil.RequestParam(ctx, "OpenIDConnectProviderArn")
|
||||
if arn == "" {
|
||||
return "", nil
|
||||
@@ -380,8 +383,12 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider":
|
||||
addRequestTagContext(condCtx, ctx)
|
||||
case "CreateUser", "CreateRole", "TagUser", "TagRole":
|
||||
addRequestTagContext(condCtx, ctx, iamutil.TagKeysFolded)
|
||||
case "CreateOpenIDConnectProvider", "TagOpenIDConnectProvider":
|
||||
addRequestTagContext(condCtx, ctx, iamutil.TagKeysExact)
|
||||
case "UntagUser", "UntagRole", "UntagOpenIDConnectProvider":
|
||||
addTagKeysContext(condCtx, ctx)
|
||||
}
|
||||
|
||||
return condCtx
|
||||
@@ -450,13 +457,13 @@ func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) {
|
||||
|
||||
// addRequestTagContext populates aws:RequestTag/<key> and aws:TagKeys from
|
||||
// the request's Tags parameter, parsed the same way the controller parses it
|
||||
// for the actual create call. A parse failure (e.g. a malformed tag) is left
|
||||
// unpopulated rather than surfaced here — the controller performs the same
|
||||
// parse independently and will reject the request with the specific
|
||||
// tag-validation error afterward, so no create can succeed with tags that
|
||||
// silently evaded a tag-scoped Condition.
|
||||
func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) {
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
// 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, keyCase iamutil.TagKeyCase) {
|
||||
tags, err := iamutil.ParseTags(ctx, keyCase)
|
||||
if err != nil || len(tags) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -468,6 +475,17 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) {
|
||||
condCtx["aws:TagKeys"] = keys
|
||||
}
|
||||
|
||||
// addTagKeysContext populates aws:TagKeys from the request's TagKeys
|
||||
// parameter. The untag actions supply keys without values, so aws:TagKeys
|
||||
// is the only tag key they 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 {
|
||||
|
||||
+104
-24
@@ -29,19 +29,21 @@ 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
|
||||
|
||||
MaxRoleNameLen = 64
|
||||
roleIDPrefix = "AROA"
|
||||
roleIDRandomLen = 17
|
||||
|
||||
@@ -184,16 +186,63 @@ 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.
|
||||
func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
|
||||
// TagKeyCase selects how a resource's tag keys are compared. IAM users and
|
||||
// roles fold key case, so "env" and "ENV" name the same tag; OIDC providers
|
||||
// compare keys exactly, so both can be carried at once.
|
||||
type TagKeyCase int
|
||||
|
||||
const (
|
||||
TagKeysFolded TagKeyCase = iota
|
||||
TagKeysExact
|
||||
)
|
||||
|
||||
// Equal reports whether a and b name the same tag key under c.
|
||||
func (c TagKeyCase) Equal(a, b string) bool {
|
||||
if c == TagKeysExact {
|
||||
return a == b
|
||||
}
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
|
||||
// normalize maps key to the form that identifies its tag under c, for use
|
||||
// as a map key.
|
||||
func (c TagKeyCase) normalize(key string) string {
|
||||
if c == TagKeysExact {
|
||||
return key
|
||||
}
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
// duplicateErr is the error reported when one request supplies the same tag
|
||||
// key twice under c.
|
||||
func (c TagKeyCase) duplicateErr() iamerr.Error {
|
||||
if c == TagKeysExact {
|
||||
return iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys)
|
||||
}
|
||||
return iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)
|
||||
}
|
||||
|
||||
// ParseTags reads IAM tag members from the request (up to
|
||||
// MaxTagMembersPerRequest), validates each, and returns the list.
|
||||
// Duplicate keys are detected under keyCase, the tagged resource's own
|
||||
// key-comparison rule.
|
||||
func ParseTags(ctx fiber.Ctx, keyCase TagKeyCase) ([]types.Tag, error) {
|
||||
var tags []types.Tag
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
@@ -206,26 +255,26 @@ 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
|
||||
}
|
||||
|
||||
normalizedKey := strings.ToLower(key)
|
||||
normalizedKey := keyCase.normalize(key)
|
||||
if _, ok := seen[normalizedKey]; ok {
|
||||
debuglogger.Logf("duplicate IAM tag key: %q", key)
|
||||
return nil, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)
|
||||
return nil, keyCase.duplicateErr()
|
||||
}
|
||||
seen[normalizedKey] = struct{}{}
|
||||
|
||||
@@ -235,6 +284,33 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// ParseTagKeys reads the request's TagKeys members (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 +405,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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -82,6 +86,10 @@ func (r *IAMApiRouter) Init() {
|
||||
"ListRoles": r.Ctrl.ListRoles,
|
||||
"DeleteRole": r.Ctrl.DeleteRole,
|
||||
"UpdateAssumeRolePolicy": r.Ctrl.UpdateAssumeRolePolicy,
|
||||
// Role Tagging
|
||||
"TagRole": r.Ctrl.TagRole,
|
||||
"UntagRole": r.Ctrl.UntagRole,
|
||||
"ListRoleTags": r.Ctrl.ListRoleTags,
|
||||
// Role Inline Policy CRUD
|
||||
"PutRolePolicy": r.Ctrl.PutRolePolicy,
|
||||
"GetRolePolicy": r.Ctrl.GetRolePolicy,
|
||||
@@ -95,6 +103,10 @@ func (r *IAMApiRouter) Init() {
|
||||
"AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider,
|
||||
"RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider,
|
||||
"UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint,
|
||||
// OIDC Provider Tagging
|
||||
"TagOpenIDConnectProvider": r.Ctrl.TagOpenIDConnectProvider,
|
||||
"UntagOpenIDConnectProvider": r.Ctrl.UntagOpenIDConnectProvider,
|
||||
"ListOpenIDConnectProviderTags": r.Ctrl.ListOpenIDConnectProviderTags,
|
||||
// STS actions (routed through this same endpoint; see stsActions)
|
||||
"AssumeRoleWithWebIdentity": r.Ctrl.AssumeRoleWithWebIdentity,
|
||||
"GetCallerIdentity": r.Ctrl.GetCallerIdentity,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// 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/internal/iamutil"
|
||||
"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
|
||||
|
||||
// MaxTagsPerResource is the maximum number of tags a single IAM user, role
|
||||
// or OIDC provider may carry at once, matching the AWS IAM quota.
|
||||
const MaxTagsPerResource = 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
|
||||
}
|
||||
|
||||
// ListTagsOutput is the paginated tag window ListUserTags and ListRoleTags
|
||||
// both return.
|
||||
type ListTagsOutput 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 ListRoleTagsInput struct {
|
||||
RoleName string
|
||||
Marker string
|
||||
MaxItems int32
|
||||
}
|
||||
|
||||
type ListOIDCProvidersOutput struct {
|
||||
Providers []types.OpenIDConnectProviderListEntry
|
||||
}
|
||||
|
||||
type ListOIDCProviderTagsInput struct {
|
||||
Arn string
|
||||
Marker string
|
||||
MaxItems int32
|
||||
}
|
||||
|
||||
// mergeTags applies the tag actions' merge semantics to existing: an
|
||||
// incoming tag replaces the existing tag whose key matches under keyCase —
|
||||
// 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 resource already at the
|
||||
// cap is allowed.
|
||||
func mergeTags(existing, incoming []types.Tag, keyCase iamutil.TagKeyCase) ([]types.Tag, error) {
|
||||
merged := slices.Clone(existing)
|
||||
for _, tag := range incoming {
|
||||
if idx := indexOfTagKey(merged, tag.Key, keyCase); idx >= 0 {
|
||||
merged[idx] = tag
|
||||
continue
|
||||
}
|
||||
if len(merged) >= MaxTagsPerResource {
|
||||
return nil, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)
|
||||
}
|
||||
merged = append(merged, tag)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// removeTags applies the untag actions' removal semantics to existing:
|
||||
// every tag whose key matches one of tagKeys under keyCase is dropped, and
|
||||
// a key naming no existing tag is ignored rather than reported.
|
||||
func removeTags(existing []types.Tag, tagKeys []string, keyCase iamutil.TagKeyCase) []types.Tag {
|
||||
return slices.DeleteFunc(slices.Clone(existing), func(tag types.Tag) bool {
|
||||
return slices.ContainsFunc(tagKeys, func(key string) bool {
|
||||
return keyCase.Equal(key, tag.Key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func indexOfTagKey(tags []types.Tag, key string, keyCase iamutil.TagKeyCase) int {
|
||||
return slices.IndexFunc(tags, func(tag types.Tag) bool {
|
||||
return keyCase.Equal(tag.Key, key)
|
||||
})
|
||||
}
|
||||
|
||||
// paginateTags sorts tags by key and applies the marker/maxItems window.
|
||||
// AWS's own tag listings return 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, marker string, maxItems int32, keyCase iamutil.TagKeyCase) *ListTagsOutput {
|
||||
sorted := slices.Clone(tags)
|
||||
slices.SortFunc(sorted, func(a, b types.Tag) int {
|
||||
return strings.Compare(a.Key, b.Key)
|
||||
})
|
||||
|
||||
if marker != "" {
|
||||
start := len(sorted)
|
||||
if idx := indexOfTagKey(sorted, marker, keyCase); idx >= 0 {
|
||||
start = idx + 1
|
||||
}
|
||||
sorted = sorted[start:]
|
||||
}
|
||||
|
||||
limit := len(sorted)
|
||||
if maxItems > 0 && int(maxItems) < limit {
|
||||
limit = int(maxItems)
|
||||
}
|
||||
|
||||
out := &ListTagsOutput{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
|
||||
}
|
||||
@@ -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, iamutil.TagKeysFolded)
|
||||
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, iamutil.TagKeysFolded)
|
||||
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) (*ListTagsOutput, 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.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
@@ -896,6 +957,67 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs
|
||||
return cloneRole(updated), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) TagRole(_ context.Context, roleName string, tags []types.Tag) error {
|
||||
return s.updateRoleTags(roleName, func(role *types.Role) error {
|
||||
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role.Tags = merged
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InternalStore) UntagRole(_ context.Context, roleName string, tagKeys []string) error {
|
||||
return s.updateRoleTags(roleName, func(role *types.Role) error {
|
||||
role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateRoleTags applies mutate to roleName's stored record and writes it
|
||||
// back under the store lock.
|
||||
func (s *InternalStore) updateRoleTags(roleName string, mutate func(*types.Role) 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, role, ok := lookupRole(conf, roleName)
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityRole(roleName)
|
||||
}
|
||||
if err := mutate(&role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf.Roles[canonical] = role
|
||||
return json.Marshal(conf)
|
||||
})
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) ListRoleTags(_ context.Context, input ListRoleTagsInput) (*ListTagsOutput, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, role, ok := lookupRole(conf, input.RoleName)
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityRole(input.RoleName)
|
||||
}
|
||||
|
||||
return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
@@ -1235,6 +1357,74 @@ func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn stri
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) TagOIDCProvider(_ context.Context, arn string, tags []types.Tag) error {
|
||||
return s.updateOIDCProviderTags(arn, func(provider *types.OIDCProvider) error {
|
||||
merged, err := mergeTags(provider.Tags, tags, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider.Tags = merged
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *InternalStore) UntagOIDCProvider(_ context.Context, arn string, tagKeys []string) error {
|
||||
return s.updateOIDCProviderTags(arn, func(provider *types.OIDCProvider) error {
|
||||
provider.Tags = removeTags(provider.Tags, tagKeys, iamutil.TagKeysExact)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateOIDCProviderTags applies mutate to arn's stored record and writes
|
||||
// it back under the store lock.
|
||||
func (s *InternalStore) updateOIDCProviderTags(arn string, mutate func(*types.OIDCProvider) 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
|
||||
}
|
||||
url, err := iamutil.ParseOIDCProviderArn(arn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider, ok := conf.OIDCProviders[url]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityOIDCProviderDelete(arn)
|
||||
}
|
||||
if err := mutate(&provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf.OIDCProviders[url] = provider
|
||||
return json.Marshal(conf)
|
||||
})
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) ListOIDCProviderTags(_ context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
url, err := iamutil.ParseOIDCProviderArn(input.Arn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, ok := conf.OIDCProviders[url]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityOIDCProviderDelete(input.Arn)
|
||||
}
|
||||
|
||||
return paginateTags(provider.Tags, input.Marker, input.MaxItems, iamutil.TagKeysExact), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) CreateSession(_ context.Context, session types.Session) (*types.Session, error) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
+12
-163
@@ -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) (*ListTagsOutput, 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
|
||||
@@ -209,6 +59,10 @@ type Storer interface {
|
||||
DeleteRole(ctx context.Context, roleName string) error
|
||||
UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error)
|
||||
|
||||
TagRole(ctx context.Context, roleName string, tags []types.Tag) error
|
||||
UntagRole(ctx context.Context, roleName string, tagKeys []string) error
|
||||
ListRoleTags(ctx context.Context, input ListRoleTagsInput) (*ListTagsOutput, error)
|
||||
|
||||
PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error
|
||||
GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error)
|
||||
DeleteRolePolicy(ctx context.Context, roleName, policyName string) error
|
||||
@@ -223,19 +77,14 @@ type Storer interface {
|
||||
RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error
|
||||
UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error
|
||||
|
||||
TagOIDCProvider(ctx context.Context, arn string, tags []types.Tag) error
|
||||
UntagOIDCProvider(ctx context.Context, arn string, tagKeys []string) error
|
||||
ListOIDCProviderTags(ctx context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error)
|
||||
|
||||
CreateSession(ctx context.Context, session types.Session) (*types.Session, error)
|
||||
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
|
||||
}
|
||||
|
||||
func unwrapAPIError(err error) error {
|
||||
var apiErr iamerr.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Dir string
|
||||
Vault VaultConfig
|
||||
|
||||
+97
-10
@@ -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, iamutil.TagKeysFolded)
|
||||
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, iamutil.TagKeysFolded)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListTagsOutput, error) {
|
||||
user, err := s.GetUser(ctx, input.UserName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return paginateTags(user.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), 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 {
|
||||
@@ -1173,6 +1202,35 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss
|
||||
})
|
||||
}
|
||||
|
||||
func (s *VaultStore) TagRole(ctx context.Context, roleName string, tags []types.Tag) error {
|
||||
_, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error {
|
||||
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role.Tags = merged
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VaultStore) UntagRole(ctx context.Context, roleName string, tagKeys []string) error {
|
||||
_, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error {
|
||||
role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VaultStore) ListRoleTags(ctx context.Context, input ListRoleTagsInput) (*ListTagsOutput, error) {
|
||||
role, err := s.GetRole(ctx, input.RoleName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error {
|
||||
_, err := s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error {
|
||||
newTotal := len(input.PolicyDocument)
|
||||
@@ -1469,7 +1527,7 @@ func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCPr
|
||||
}
|
||||
|
||||
func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) {
|
||||
provider, _, err := s.readOIDCProviderVersion(arn)
|
||||
provider, _, err := s.readOIDCProviderVersion(arn, iamerr.NoSuchEntityOIDCProviderGet)
|
||||
return provider, err
|
||||
}
|
||||
|
||||
@@ -1477,8 +1535,10 @@ func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDC
|
||||
// readUserVersion/readRoleVersion: it additionally returns the KV version
|
||||
// the record was read at, so a mutation can write back with a matching CAS
|
||||
// value instead of racing on a blind delete-then-recreate (see
|
||||
// replaceOIDCProvider).
|
||||
func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, int32, error) {
|
||||
// replaceOIDCProvider). notFound supplies the NoSuchEntity error the
|
||||
// calling action reports, which the tagging actions word differently from
|
||||
// the rest.
|
||||
func (s *VaultStore) readOIDCProviderVersion(arn string, notFound func(string) iamerr.Error) (*types.OIDCProvider, int32, error) {
|
||||
url, err := iamutil.ParseOIDCProviderArn(arn)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -1489,7 +1549,7 @@ func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, i
|
||||
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn)
|
||||
return nil, 0, notFound(arn)
|
||||
}
|
||||
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
||||
return nil, 0, reauthErr
|
||||
@@ -1497,7 +1557,7 @@ func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, i
|
||||
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn)
|
||||
return nil, 0, notFound(arn)
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -1599,7 +1659,7 @@ func (s *VaultStore) deleteOIDCProviderByURL(url string) error {
|
||||
}
|
||||
|
||||
func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error {
|
||||
if slices.Contains(provider.ClientIDList, clientID) {
|
||||
return nil
|
||||
}
|
||||
@@ -1612,7 +1672,7 @@ func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientI
|
||||
}
|
||||
|
||||
func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error {
|
||||
idx := slices.Index(provider.ClientIDList, clientID)
|
||||
if idx == -1 {
|
||||
return nil
|
||||
@@ -1623,12 +1683,39 @@ func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, cl
|
||||
}
|
||||
|
||||
func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error {
|
||||
provider.ThumbprintList = thumbprints
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *VaultStore) TagOIDCProvider(ctx context.Context, arn string, tags []types.Tag) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderDelete, func(provider *types.OIDCProvider) error {
|
||||
merged, err := mergeTags(provider.Tags, tags, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider.Tags = merged
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *VaultStore) UntagOIDCProvider(ctx context.Context, arn string, tagKeys []string) error {
|
||||
return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderDelete, func(provider *types.OIDCProvider) error {
|
||||
provider.Tags = removeTags(provider.Tags, tagKeys, iamutil.TagKeysExact)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *VaultStore) ListOIDCProviderTags(_ context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error) {
|
||||
provider, _, err := s.readOIDCProviderVersion(input.Arn, iamerr.NoSuchEntityOIDCProviderDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return paginateTags(provider.Tags, input.Marker, input.MaxItems, iamutil.TagKeysExact), nil
|
||||
}
|
||||
|
||||
// replaceOIDCProvider overwrites the stored document for provider.Url using
|
||||
// a version-checked (CAS) write tied to readVersion, instead of an
|
||||
// unconditional delete-then-recreate — see replaceUser for the rationale.
|
||||
@@ -1665,9 +1752,9 @@ func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OID
|
||||
}
|
||||
|
||||
// withOIDCProviderCAS is withUserCAS's counterpart for OIDC providers.
|
||||
func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, mutate func(*types.OIDCProvider) error) error {
|
||||
func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, notFound func(string) iamerr.Error, mutate func(*types.OIDCProvider) error) error {
|
||||
for range maxCASRetries {
|
||||
provider, version, err := s.readOIDCProviderVersion(arn)
|
||||
provider, version, err := s.readOIDCProviderVersion(arn, notFound)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -127,3 +127,37 @@ type UpdateOpenIDConnectProviderThumbprintResponse struct {
|
||||
func (r *UpdateOpenIDConnectProviderThumbprintResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type TagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *TagOpenIDConnectProviderResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type UntagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *UntagOpenIDConnectProviderResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type ListOpenIDConnectProviderTagsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProviderTagsResponse"`
|
||||
Result ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *ListOpenIDConnectProviderTagsResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type ListOpenIDConnectProviderTagsResult struct {
|
||||
Tags Tags
|
||||
IsTruncated bool
|
||||
Marker string `xml:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -111,3 +111,37 @@ type UpdateAssumeRolePolicyResponse struct {
|
||||
func (r *UpdateAssumeRolePolicyResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type TagRoleResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagRoleResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *TagRoleResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type UntagRoleResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagRoleResponse"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *UntagRoleResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type ListRoleTagsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRoleTagsResponse"`
|
||||
Result ListRoleTagsResult `xml:"ListRoleTagsResult"`
|
||||
ResponseMetadata ResponseMetadata
|
||||
}
|
||||
|
||||
func (r *ListRoleTagsResponse) SetRequestID(requestID string) {
|
||||
r.ResponseMetadata.RequestID = requestID
|
||||
}
|
||||
|
||||
type ListRoleTagsResult struct {
|
||||
Tags Tags
|
||||
IsTruncated bool
|
||||
Marker string `xml:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -113,3 +113,44 @@ type Tag struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Tags is the XML wrapper for a tag list, rendering as
|
||||
// <Tags><member>…</member></Tags> — and, when empty, as <Tags/> 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"`
|
||||
}
|
||||
|
||||
@@ -479,31 +479,16 @@ func PutObjectRetention_shorten_governance_with_bypass(s *S3Conf) error {
|
||||
func PutObjectRetention_shorten_compliance_denied(s *S3Conf) error {
|
||||
testName := "PutObjectRetention_shorten_compliance_denied"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
date := time.Now().Add(complianceTestRetention)
|
||||
obj := "my-obj"
|
||||
_, err := putObjects(s3client, []string{obj}, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{
|
||||
Bucket: &bucket,
|
||||
Key: &obj,
|
||||
Retention: &types.ObjectLockRetention{
|
||||
Mode: types.ObjectLockRetentionModeCompliance,
|
||||
RetainUntilDate: &date,
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policy := genPolicyDoc("Allow", fmt.Sprintf(`"%v"`, s.awsID), `["s3:BypassGovernanceRetention"]`, fmt.Sprintf(`"arn:aws:s3:::%v/*"`, bucket))
|
||||
bypass := true
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||
Bucket: &bucket,
|
||||
Policy: &policy,
|
||||
@@ -513,6 +498,27 @@ func PutObjectRetention_shorten_compliance_denied(s *S3Conf) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// The COMPLIANCE lock goes on last, after the slow setup above rather
|
||||
// than before it, so the whole complianceTestRetention budget is left
|
||||
// for the two attempts below. Started any earlier, a slow object upload
|
||||
// can consume the entire window and leave an already expired retention
|
||||
// with nothing to shorten.
|
||||
date := time.Now().Add(complianceTestRetention)
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{
|
||||
Bucket: &bucket,
|
||||
Key: &obj,
|
||||
Retention: &types.ObjectLockRetention{
|
||||
Mode: types.ObjectLockRetentionModeCompliance,
|
||||
RetainUntilDate: &date,
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Recomputed fresh right before each request below, rather than once
|
||||
// up front: the server rejects a RetainUntilDate that has already
|
||||
// passed (InvalidArgument) before it ever gets to the object-lock
|
||||
|
||||
@@ -1196,6 +1196,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)
|
||||
@@ -1358,6 +1403,53 @@ func TestIAMUpdateAssumeRolePolicy(ts *TestState) {
|
||||
ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar)
|
||||
}
|
||||
|
||||
func TestIAMTagRole(ts *TestState) {
|
||||
ts.Run(IAMTagRole_missing_role_name)
|
||||
ts.Run(IAMTagRole_invalid_role_name)
|
||||
ts.Run(IAMTagRole_role_name_too_long)
|
||||
ts.Run(IAMTagRole_missing_tags)
|
||||
ts.Run(IAMTagRole_missing_tag_key)
|
||||
ts.Run(IAMTagRole_missing_tag_value)
|
||||
ts.Run(IAMTagRole_empty_tag_key)
|
||||
ts.Run(IAMTagRole_tag_key_too_long)
|
||||
ts.Run(IAMTagRole_invalid_tag_key)
|
||||
ts.Run(IAMTagRole_tag_value_too_long)
|
||||
ts.Run(IAMTagRole_invalid_tag_value)
|
||||
ts.Run(IAMTagRole_duplicate_tag_keys)
|
||||
ts.Run(IAMTagRole_too_many_tags)
|
||||
ts.Run(IAMTagRole_non_existing_role)
|
||||
ts.Run(IAMTagRole_tag_limit_exceeded)
|
||||
ts.Run(IAMTagRole_success)
|
||||
ts.Run(IAMTagRole_overwrites_existing_tag)
|
||||
ts.Run(IAMTagRole_isolated_from_same_named_user)
|
||||
}
|
||||
|
||||
func TestIAMUntagRole(ts *TestState) {
|
||||
ts.Run(IAMUntagRole_missing_role_name)
|
||||
ts.Run(IAMUntagRole_invalid_role_name)
|
||||
ts.Run(IAMUntagRole_role_name_too_long)
|
||||
ts.Run(IAMUntagRole_missing_tag_keys)
|
||||
ts.Run(IAMUntagRole_invalid_tag_key)
|
||||
ts.Run(IAMUntagRole_too_many_tag_keys)
|
||||
ts.Run(IAMUntagRole_non_existing_role)
|
||||
ts.Run(IAMUntagRole_success)
|
||||
ts.Run(IAMUntagRole_removal_is_idempotent)
|
||||
ts.Run(IAMUntagRole_case_insensitive_key)
|
||||
ts.Run(IAMUntagRole_removes_only_named_keys)
|
||||
}
|
||||
|
||||
func TestIAMListRoleTags(ts *TestState) {
|
||||
ts.Run(IAMListRoleTags_missing_role_name)
|
||||
ts.Run(IAMListRoleTags_invalid_role_name)
|
||||
ts.Run(IAMListRoleTags_role_name_too_long)
|
||||
ts.Run(IAMListRoleTags_invalid_max_items)
|
||||
ts.Run(IAMListRoleTags_invalid_max_items_format)
|
||||
ts.Run(IAMListRoleTags_non_existing_role)
|
||||
ts.Run(IAMListRoleTags_empty_result)
|
||||
ts.Run(IAMListRoleTags_success)
|
||||
ts.Run(IAMListRoleTags_pagination)
|
||||
}
|
||||
|
||||
func TestIAMPutRolePolicy(ts *TestState) {
|
||||
ts.Run(IAMPutRolePolicy_missing_role_name)
|
||||
ts.Run(IAMPutRolePolicy_missing_policy_name)
|
||||
@@ -1463,6 +1555,47 @@ func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) {
|
||||
ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints)
|
||||
}
|
||||
|
||||
func TestIAMTagOpenIDConnectProvider(ts *TestState) {
|
||||
ts.Run(IAMTagOpenIDConnectProvider_missing_arn)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_invalid_arn)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_missing_tags)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_missing_tag_key)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_missing_tag_value)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_invalid_tags)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_duplicate_tag_keys)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_too_many_tags)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_non_existing_provider)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_tag_limit_exceeded)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_success)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_overwrites_existing_tag)
|
||||
ts.Run(IAMTagOpenIDConnectProvider_isolated_per_provider)
|
||||
}
|
||||
|
||||
func TestIAMUntagOpenIDConnectProvider(ts *TestState) {
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_missing_arn)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_invalid_arn)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_missing_tag_keys)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_invalid_tag_key)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_too_many_tag_keys)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_non_existing_provider)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_success)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_removal_is_idempotent)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_case_sensitive_key)
|
||||
ts.Run(IAMUntagOpenIDConnectProvider_removes_only_named_keys)
|
||||
}
|
||||
|
||||
func TestIAMListOpenIDConnectProviderTags(ts *TestState) {
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_missing_arn)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_invalid_arn)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_invalid_max_items)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_invalid_max_items_format)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_non_existing_provider)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_empty_result)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_success)
|
||||
ts.Run(IAMListOpenIDConnectProviderTags_pagination)
|
||||
}
|
||||
|
||||
func TestIAMAssumeRoleWithWebIdentity(ts *TestState) {
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_missing_role_arn)
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_too_short)
|
||||
@@ -1637,6 +1770,9 @@ func TestIAM(ts *TestState) {
|
||||
TestIAMListUsers(ts)
|
||||
TestIAMDeleteUser(ts)
|
||||
TestIAMUpdateUser(ts)
|
||||
TestIAMTagUser(ts)
|
||||
TestIAMUntagUser(ts)
|
||||
TestIAMListUserTags(ts)
|
||||
TestIAMCreateAccessKey(ts)
|
||||
TestIAMUpdateAccessKey(ts)
|
||||
TestIAMDeleteAccessKey(ts)
|
||||
@@ -1651,6 +1787,9 @@ func TestIAM(ts *TestState) {
|
||||
TestIAMListRoles(ts)
|
||||
TestIAMDeleteRole(ts)
|
||||
TestIAMUpdateAssumeRolePolicy(ts)
|
||||
TestIAMTagRole(ts)
|
||||
TestIAMUntagRole(ts)
|
||||
TestIAMListRoleTags(ts)
|
||||
TestIAMPutRolePolicy(ts)
|
||||
TestIAMGetRolePolicy(ts)
|
||||
TestIAMDeleteRolePolicy(ts)
|
||||
@@ -1662,6 +1801,9 @@ func TestIAM(ts *TestState) {
|
||||
TestIAMAddClientIDToOpenIDConnectProvider(ts)
|
||||
TestIAMRemoveClientIDFromOpenIDConnectProvider(ts)
|
||||
TestIAMUpdateOpenIDConnectProviderThumbprint(ts)
|
||||
TestIAMTagOpenIDConnectProvider(ts)
|
||||
TestIAMUntagOpenIDConnectProvider(ts)
|
||||
TestIAMListOpenIDConnectProviderTags(ts)
|
||||
TestIAMAssumeRoleWithWebIdentity(ts)
|
||||
TestIAMGetCallerIdentity(ts)
|
||||
TestIAMAccessControl(ts)
|
||||
@@ -2127,6 +2269,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,
|
||||
@@ -2247,6 +2425,44 @@ func GetIntTests() IntTests {
|
||||
"IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded,
|
||||
"IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success,
|
||||
"IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar,
|
||||
"IAMTagRole_missing_role_name": IAMTagRole_missing_role_name,
|
||||
"IAMTagRole_invalid_role_name": IAMTagRole_invalid_role_name,
|
||||
"IAMTagRole_role_name_too_long": IAMTagRole_role_name_too_long,
|
||||
"IAMTagRole_missing_tags": IAMTagRole_missing_tags,
|
||||
"IAMTagRole_missing_tag_key": IAMTagRole_missing_tag_key,
|
||||
"IAMTagRole_missing_tag_value": IAMTagRole_missing_tag_value,
|
||||
"IAMTagRole_empty_tag_key": IAMTagRole_empty_tag_key,
|
||||
"IAMTagRole_tag_key_too_long": IAMTagRole_tag_key_too_long,
|
||||
"IAMTagRole_invalid_tag_key": IAMTagRole_invalid_tag_key,
|
||||
"IAMTagRole_tag_value_too_long": IAMTagRole_tag_value_too_long,
|
||||
"IAMTagRole_invalid_tag_value": IAMTagRole_invalid_tag_value,
|
||||
"IAMTagRole_duplicate_tag_keys": IAMTagRole_duplicate_tag_keys,
|
||||
"IAMTagRole_too_many_tags": IAMTagRole_too_many_tags,
|
||||
"IAMTagRole_non_existing_role": IAMTagRole_non_existing_role,
|
||||
"IAMTagRole_tag_limit_exceeded": IAMTagRole_tag_limit_exceeded,
|
||||
"IAMTagRole_success": IAMTagRole_success,
|
||||
"IAMTagRole_overwrites_existing_tag": IAMTagRole_overwrites_existing_tag,
|
||||
"IAMTagRole_isolated_from_same_named_user": IAMTagRole_isolated_from_same_named_user,
|
||||
"IAMUntagRole_missing_role_name": IAMUntagRole_missing_role_name,
|
||||
"IAMUntagRole_invalid_role_name": IAMUntagRole_invalid_role_name,
|
||||
"IAMUntagRole_role_name_too_long": IAMUntagRole_role_name_too_long,
|
||||
"IAMUntagRole_missing_tag_keys": IAMUntagRole_missing_tag_keys,
|
||||
"IAMUntagRole_invalid_tag_key": IAMUntagRole_invalid_tag_key,
|
||||
"IAMUntagRole_too_many_tag_keys": IAMUntagRole_too_many_tag_keys,
|
||||
"IAMUntagRole_non_existing_role": IAMUntagRole_non_existing_role,
|
||||
"IAMUntagRole_success": IAMUntagRole_success,
|
||||
"IAMUntagRole_removal_is_idempotent": IAMUntagRole_removal_is_idempotent,
|
||||
"IAMUntagRole_case_insensitive_key": IAMUntagRole_case_insensitive_key,
|
||||
"IAMUntagRole_removes_only_named_keys": IAMUntagRole_removes_only_named_keys,
|
||||
"IAMListRoleTags_missing_role_name": IAMListRoleTags_missing_role_name,
|
||||
"IAMListRoleTags_invalid_role_name": IAMListRoleTags_invalid_role_name,
|
||||
"IAMListRoleTags_role_name_too_long": IAMListRoleTags_role_name_too_long,
|
||||
"IAMListRoleTags_invalid_max_items": IAMListRoleTags_invalid_max_items,
|
||||
"IAMListRoleTags_invalid_max_items_format": IAMListRoleTags_invalid_max_items_format,
|
||||
"IAMListRoleTags_non_existing_role": IAMListRoleTags_non_existing_role,
|
||||
"IAMListRoleTags_empty_result": IAMListRoleTags_empty_result,
|
||||
"IAMListRoleTags_success": IAMListRoleTags_success,
|
||||
"IAMListRoleTags_pagination": IAMListRoleTags_pagination,
|
||||
"IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name,
|
||||
"IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name,
|
||||
"IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document,
|
||||
@@ -2319,6 +2535,38 @@ func GetIntTests() IntTests {
|
||||
"IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider,
|
||||
"IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success,
|
||||
"IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints,
|
||||
"IAMTagOpenIDConnectProvider_missing_arn": IAMTagOpenIDConnectProvider_missing_arn,
|
||||
"IAMTagOpenIDConnectProvider_invalid_arn": IAMTagOpenIDConnectProvider_invalid_arn,
|
||||
"IAMTagOpenIDConnectProvider_missing_tags": IAMTagOpenIDConnectProvider_missing_tags,
|
||||
"IAMTagOpenIDConnectProvider_missing_tag_key": IAMTagOpenIDConnectProvider_missing_tag_key,
|
||||
"IAMTagOpenIDConnectProvider_missing_tag_value": IAMTagOpenIDConnectProvider_missing_tag_value,
|
||||
"IAMTagOpenIDConnectProvider_invalid_tags": IAMTagOpenIDConnectProvider_invalid_tags,
|
||||
"IAMTagOpenIDConnectProvider_duplicate_tag_keys": IAMTagOpenIDConnectProvider_duplicate_tag_keys,
|
||||
"IAMTagOpenIDConnectProvider_too_many_tags": IAMTagOpenIDConnectProvider_too_many_tags,
|
||||
"IAMTagOpenIDConnectProvider_non_existing_provider": IAMTagOpenIDConnectProvider_non_existing_provider,
|
||||
"IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup": IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup,
|
||||
"IAMTagOpenIDConnectProvider_tag_limit_exceeded": IAMTagOpenIDConnectProvider_tag_limit_exceeded,
|
||||
"IAMTagOpenIDConnectProvider_success": IAMTagOpenIDConnectProvider_success,
|
||||
"IAMTagOpenIDConnectProvider_overwrites_existing_tag": IAMTagOpenIDConnectProvider_overwrites_existing_tag,
|
||||
"IAMTagOpenIDConnectProvider_isolated_per_provider": IAMTagOpenIDConnectProvider_isolated_per_provider,
|
||||
"IAMUntagOpenIDConnectProvider_missing_arn": IAMUntagOpenIDConnectProvider_missing_arn,
|
||||
"IAMUntagOpenIDConnectProvider_invalid_arn": IAMUntagOpenIDConnectProvider_invalid_arn,
|
||||
"IAMUntagOpenIDConnectProvider_missing_tag_keys": IAMUntagOpenIDConnectProvider_missing_tag_keys,
|
||||
"IAMUntagOpenIDConnectProvider_invalid_tag_key": IAMUntagOpenIDConnectProvider_invalid_tag_key,
|
||||
"IAMUntagOpenIDConnectProvider_too_many_tag_keys": IAMUntagOpenIDConnectProvider_too_many_tag_keys,
|
||||
"IAMUntagOpenIDConnectProvider_non_existing_provider": IAMUntagOpenIDConnectProvider_non_existing_provider,
|
||||
"IAMUntagOpenIDConnectProvider_success": IAMUntagOpenIDConnectProvider_success,
|
||||
"IAMUntagOpenIDConnectProvider_removal_is_idempotent": IAMUntagOpenIDConnectProvider_removal_is_idempotent,
|
||||
"IAMUntagOpenIDConnectProvider_case_sensitive_key": IAMUntagOpenIDConnectProvider_case_sensitive_key,
|
||||
"IAMUntagOpenIDConnectProvider_removes_only_named_keys": IAMUntagOpenIDConnectProvider_removes_only_named_keys,
|
||||
"IAMListOpenIDConnectProviderTags_missing_arn": IAMListOpenIDConnectProviderTags_missing_arn,
|
||||
"IAMListOpenIDConnectProviderTags_invalid_arn": IAMListOpenIDConnectProviderTags_invalid_arn,
|
||||
"IAMListOpenIDConnectProviderTags_invalid_max_items": IAMListOpenIDConnectProviderTags_invalid_max_items,
|
||||
"IAMListOpenIDConnectProviderTags_invalid_max_items_format": IAMListOpenIDConnectProviderTags_invalid_max_items_format,
|
||||
"IAMListOpenIDConnectProviderTags_non_existing_provider": IAMListOpenIDConnectProviderTags_non_existing_provider,
|
||||
"IAMListOpenIDConnectProviderTags_empty_result": IAMListOpenIDConnectProviderTags_empty_result,
|
||||
"IAMListOpenIDConnectProviderTags_success": IAMListOpenIDConnectProviderTags_success,
|
||||
"IAMListOpenIDConnectProviderTags_pagination": IAMListOpenIDConnectProviderTags_pagination,
|
||||
"IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn,
|
||||
"IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short,
|
||||
"IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration,
|
||||
|
||||
@@ -133,6 +133,9 @@ func IAMCreateOpenIDConnectProvider_invalid_thumbprint(s *S3Conf) error {
|
||||
})
|
||||
}
|
||||
|
||||
// IAMCreateOpenIDConnectProvider_duplicate_tag_keys covers the provider
|
||||
// actions' exact tag-key comparison: only a byte-identical repeat is a
|
||||
// duplicate, so a differently-cased repeat creates two distinct tags.
|
||||
func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMCreateOpenIDConnectProvider_duplicate_tag_keys"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
@@ -141,10 +144,27 @@ func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error {
|
||||
ThumbprintList: []string{validOIDCThumbprint},
|
||||
Tags: []iamtypes.Tag{
|
||||
{Key: aws.String("key"), Value: aws.String("one")},
|
||||
{Key: aws.String("KEY"), Value: aws.String("two")},
|
||||
{Key: aws.String("key"), Value: aws.String("two")},
|
||||
},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive."))
|
||||
if err := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
arn, err := createOIDCProviderReturningArnWithTags(client, []iamtypes.Tag{
|
||||
{Key: aws.String("key"), Value: aws.String("one")},
|
||||
{Key: aws.String("KEY"), Value: aws.String("two")},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("differently-cased keys: %w", err)
|
||||
}
|
||||
|
||||
checkErr := checkIAMOIDCProviderTags(client, arn, map[string]string{"key": "one", "KEY": "two"})
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
@@ -469,6 +489,20 @@ func oidcProviderArn(providerURL string) string {
|
||||
return "arn:aws:iam::000000000000:oidc-provider/" + strings.TrimPrefix(providerURL, "https://")
|
||||
}
|
||||
|
||||
// createOIDCProviderReturningArnWithTags creates a provider at a fresh
|
||||
// random URL carrying tags and returns its ARN.
|
||||
func createOIDCProviderReturningArnWithTags(client *iam.Client, tags []iamtypes.Tag) (string, error) {
|
||||
out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{
|
||||
Url: aws.String(newIAMOIDCProviderURL()),
|
||||
ThumbprintList: []string{validOIDCThumbprint},
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return aws.ToString(out.OpenIDConnectProviderArn), nil
|
||||
}
|
||||
|
||||
func createOIDCProviderReturningArn(client *iam.Client, thumbprints []string) (string, error) {
|
||||
out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{
|
||||
Url: aws.String(newIAMOIDCProviderURL()),
|
||||
|
||||
@@ -53,8 +53,8 @@ func IAMDeleteRole_invalid_role_name(s *S3Conf) error {
|
||||
func IAMDeleteRole_long_role_name(s *S3Conf) error {
|
||||
testName := "IAMDeleteRole_long_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
err := deleteIAMRole(client, strings.Repeat("a", 129))
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128))
|
||||
err := deleteIAMRole(client, strings.Repeat("a", 65))
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ func IAMGetRole_invalid_role_name(s *S3Conf) error {
|
||||
func IAMGetRole_long_role_name(s *S3Conf) error {
|
||||
testName := "IAMGetRole_long_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := getIAMRole(client, strings.Repeat("a", 129))
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128))
|
||||
_, err := getIAMRole(client, strings.Repeat("a", 65))
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// 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"
|
||||
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
)
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_missing_arn(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_missing_arn"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"ListOpenIDConnectProviderTags"},
|
||||
"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("openIDConnectProviderArn"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_invalid_arn(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_invalid_arn"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
tests := []struct {
|
||||
name string
|
||||
arn string
|
||||
want iamerr.Error
|
||||
}{
|
||||
{"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)},
|
||||
{"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)},
|
||||
{"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")},
|
||||
{"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{
|
||||
OpenIDConnectProviderArn: aws.String(tt.arn),
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil {
|
||||
return fmt.Errorf("%s: %w", tt.name, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_invalid_max_items(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_invalid_max_items"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://validprovider.example.com")
|
||||
for maxItems, expected := range map[int32]iamerr.Error{
|
||||
-1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
|
||||
0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
|
||||
1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
|
||||
} {
|
||||
_, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
MaxItems: aws.Int32(maxItems),
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
|
||||
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_invalid_max_items_format(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_invalid_max_items_format"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"ListOpenIDConnectProviderTags"},
|
||||
"Version": {"2010-05-08"},
|
||||
"OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")},
|
||||
"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 IAMListOpenIDConnectProviderTags_non_existing_provider(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_non_existing_provider"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://" + genRandString(16) + ".example.com")
|
||||
_, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_empty_result(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_empty_result"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn})
|
||||
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 := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_success(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createOIDCProviderReturningArnWithTags(client, []iamtypes.Tag{
|
||||
{Key: aws.String("created"), Value: aws.String("at-create-time")},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected ListOpenIDConnectProviderTags 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: the tag action merges into whatever create stored.
|
||||
return compareIAMTags(out.Tags, map[string]string{
|
||||
"created": "at-create-time",
|
||||
"env": "prod",
|
||||
"team": "storage",
|
||||
})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListOpenIDConnectProviderTags_pagination(s *S3Conf) error {
|
||||
testName := "IAMListOpenIDConnectProviderTags_pagination"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if 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 := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(tags),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input := iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn, MaxItems: aws.Int32(1)}
|
||||
var pages []*iam.ListOpenIDConnectProviderTagsOutput
|
||||
for {
|
||||
out, err := listIAMOIDCProviderTags(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 := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func listIAMOIDCProviderTags(client *iam.Client, input *iam.ListOpenIDConnectProviderTagsInput) (*iam.ListOpenIDConnectProviderTagsOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.ListOpenIDConnectProviderTags(ctx, input)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// 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 IAMListRoleTags_missing_role_name(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_missing_role_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"ListRoleTags"},
|
||||
"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("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_invalid_role_name(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_invalid_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{
|
||||
RoleName: aws.String("invalid role name"),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidUserName("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_role_name_too_long(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_role_name_too_long"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{
|
||||
RoleName: aws.String(strings.Repeat("a", 65)),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_invalid_max_items(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_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 := listIAMRoleTags(client, &iam.ListRoleTagsInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
MaxItems: aws.Int32(maxItems),
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
|
||||
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_invalid_max_items_format(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_invalid_max_items_format"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"ListRoleTags"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"validrolename"},
|
||||
"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 IAMListRoleTags_non_existing_role(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_non_existing_role"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName := "non-existing-" + genRandString(16)
|
||||
_, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_empty_result(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_empty_result"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName})
|
||||
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 := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_success(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, iamTagList(map[string]string{"created": "at-create-time"}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected ListRoleTags 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: TagRole merges into whatever CreateRole stored.
|
||||
return compareIAMTags(out.Tags, map[string]string{
|
||||
"created": "at-create-time",
|
||||
"env": "prod",
|
||||
"team": "storage",
|
||||
})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMListRoleTags_pagination(s *S3Conf) error {
|
||||
testName := "IAMListRoleTags_pagination"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if 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 := tagIAMRole(client, &iam.TagRoleInput{RoleName: &roleName, Tags: iamTagList(tags)}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input := iam.ListRoleTagsInput{RoleName: &roleName, MaxItems: aws.Int32(1)}
|
||||
var pages []*iam.ListRoleTagsOutput
|
||||
for {
|
||||
out, err := listIAMRoleTags(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 := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func listIAMRoleTags(client *iam.Client, input *iam.ListRoleTagsInput) (*iam.ListRoleTagsOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.ListRoleTags(ctx, input)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
// 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 IAMTagOpenIDConnectProvider_missing_arn(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_missing_arn"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagOpenIDConnectProvider"},
|
||||
"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("openIDConnectProviderArn"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_invalid_arn(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_invalid_arn"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
tests := []struct {
|
||||
name string
|
||||
arn string
|
||||
want iamerr.Error
|
||||
}{
|
||||
{"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)},
|
||||
{"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)},
|
||||
{"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")},
|
||||
{"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(tt.arn),
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil {
|
||||
return fmt.Errorf("%s: %w", tt.name, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_missing_tags(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_missing_tags"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagOpenIDConnectProvider"},
|
||||
"Version": {"2010-05-08"},
|
||||
"OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("tags"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_missing_tag_key(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_missing_tag_key"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagOpenIDConnectProvider"},
|
||||
"Version": {"2010-05-08"},
|
||||
"OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")},
|
||||
"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 IAMTagOpenIDConnectProvider_missing_tag_value(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_missing_tag_value"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagOpenIDConnectProvider"},
|
||||
"Version": {"2010-05-08"},
|
||||
"OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")},
|
||||
"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 IAMTagOpenIDConnectProvider_invalid_tags(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_invalid_tags"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://validprovider.example.com")
|
||||
tests := []struct {
|
||||
name string
|
||||
tags []iamtypes.Tag
|
||||
want iamerr.Error
|
||||
}{
|
||||
{"empty_key", []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}}, iamerr.TagKeyTooShort(1)},
|
||||
{"key_too_long", iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}), iamerr.TagKeyTooLong(1)},
|
||||
{"invalid_key", iamTagList(map[string]string{"invalid*key": "prod"}), iamerr.InvalidTagKey(1)},
|
||||
{"value_too_long", iamTagList(map[string]string{"env": strings.Repeat("v", 257)}), iamerr.TagValueTooLong(1)},
|
||||
{"invalid_value", iamTagList(map[string]string{"env": "invalid*value"}), iamerr.InvalidTagValue(1)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: tt.tags,
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil {
|
||||
return fmt.Errorf("%s: %w", tt.name, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagOpenIDConnectProvider_duplicate_tag_keys covers the provider
|
||||
// actions' exact tag-key comparison: an identical key twice in one request
|
||||
// is rejected, where a differently-cased repeat is two separate tags.
|
||||
func IAMTagOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_duplicate_tag_keys"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: []iamtypes.Tag{
|
||||
{Key: aws.String("env"), Value: aws.String("prod")},
|
||||
{Key: aws.String("env"), Value: aws.String("staging")},
|
||||
},
|
||||
})
|
||||
if err := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: []iamtypes.Tag{
|
||||
{Key: aws.String("env"), Value: aws.String("prod")},
|
||||
{Key: aws.String("ENV"), Value: aws.String("staging")},
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("differently-cased keys: %w", err)
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod", "ENV": "staging"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_too_many_tags(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_too_many_tags"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://validprovider.example.com")),
|
||||
Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_non_existing_provider(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_non_existing_provider"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://" + genRandString(16) + ".example.com")
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn))
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup covers a
|
||||
// malformed tag naming a provider that does not exist: the request is
|
||||
// validated in full before the provider is looked up, so the tag error
|
||||
// wins over NoSuchEntity.
|
||||
func IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://" + genRandString(16) + ".example.com")),
|
||||
Tags: iamTagList(map[string]string{"invalid*key": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidTagKey(1))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_tag_limit_exceeded(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_tag_limit_exceeded"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: numberedIAMTags(1, storage.MaxTagsPerResource),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"key1": "replaced"}),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("replacing a tag at the quota: %w", err)
|
||||
}
|
||||
|
||||
// A differently-cased key is a new tag, so it overflows.
|
||||
_, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"KEY1": "x"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded))
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagOpenIDConnectProvider_success(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if 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 := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(want),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected TagOpenIDConnectProvider response request id")
|
||||
}
|
||||
|
||||
if err := checkIAMOIDCProviderTags(client, arn, want); err != nil {
|
||||
return err
|
||||
}
|
||||
// GetOpenIDConnectProvider reports the same tags the tag actions maintain.
|
||||
provider, err := getIAMOIDCProvider(client, arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return compareIAMTags(provider.Tags, want)
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagOpenIDConnectProvider_overwrites_existing_tag covers re-tagging a
|
||||
// key that is already present: the value is replaced rather than added
|
||||
// alongside, while a differently-cased key is a separate tag that leaves
|
||||
// the original in place.
|
||||
func IAMTagOpenIDConnectProvider_overwrites_existing_tag(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_overwrites_existing_tag"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "staging"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "staging"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"ENV": "qa"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "staging", "ENV": "qa"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagOpenIDConnectProvider_isolated_per_provider covers two providers
|
||||
// carrying the same tag keys: each set is its own, in both directions.
|
||||
func IAMTagOpenIDConnectProvider_isolated_per_provider(s *S3Conf) error {
|
||||
testName := "IAMTagOpenIDConnectProvider_isolated_per_provider"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
first, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
second, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
deleteOIDCProvider(client, first)
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &first,
|
||||
Tags: iamTagList(map[string]string{"owner": "first"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &second,
|
||||
Tags: iamTagList(map[string]string{"owner": "second"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkIAMOIDCProviderTags(client, first, map[string]string{"owner": "first"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMOIDCProviderTags(client, second, map[string]string{"owner": "second"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Untagging one must not strip the other's identically-named key.
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &first,
|
||||
TagKeys: []string{"owner"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMOIDCProviderTags(client, first, map[string]string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, second, map[string]string{"owner": "second"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, first)
|
||||
if secondErr := deleteOIDCProvider(client, second); deleteErr == nil {
|
||||
deleteErr = secondErr
|
||||
}
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func tagOIDCProvider(client *iam.Client, input *iam.TagOpenIDConnectProviderInput) (*iam.TagOpenIDConnectProviderOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.TagOpenIDConnectProvider(ctx, input)
|
||||
}
|
||||
|
||||
// checkIAMOIDCProviderTags asserts ListOpenIDConnectProviderTags reports
|
||||
// exactly want for arn.
|
||||
func checkIAMOIDCProviderTags(client *iam.Client, arn string, want map[string]string) error {
|
||||
out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out.IsTruncated {
|
||||
return fmt.Errorf("expected IsTruncated to be false")
|
||||
}
|
||||
return compareIAMTags(out.Tags, want)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// 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 IAMTagRole_missing_role_name(s *S3Conf) error {
|
||||
testName := "IAMTagRole_missing_role_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagRole"},
|
||||
"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("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_invalid_role_name(s *S3Conf) error {
|
||||
testName := "IAMTagRole_invalid_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("invalid role name"),
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidUserName("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_role_name_too_long(s *S3Conf) error {
|
||||
testName := "IAMTagRole_role_name_too_long"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String(strings.Repeat("a", 65)),
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_missing_tags(s *S3Conf) error {
|
||||
testName := "IAMTagRole_missing_tags"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagRole"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"validrolename"},
|
||||
}.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 IAMTagRole_missing_tag_key(s *S3Conf) error {
|
||||
testName := "IAMTagRole_missing_tag_key"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagRole"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"validrolename"},
|
||||
"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 IAMTagRole_missing_tag_value(s *S3Conf) error {
|
||||
testName := "IAMTagRole_missing_tag_value"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"TagRole"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"validrolename"},
|
||||
"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 IAMTagRole_empty_tag_key(s *S3Conf) error {
|
||||
testName := "IAMTagRole_empty_tag_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.TagKeyTooShort(1))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_tag_key_too_long(s *S3Conf) error {
|
||||
testName := "IAMTagRole_tag_key_too_long"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.TagKeyTooLong(1))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_invalid_tag_key(s *S3Conf) error {
|
||||
testName := "IAMTagRole_invalid_tag_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: iamTagList(map[string]string{"invalid*key": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidTagKey(1))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_tag_value_too_long(s *S3Conf) error {
|
||||
testName := "IAMTagRole_tag_value_too_long"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: iamTagList(map[string]string{"env": strings.Repeat("v", 257)}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.TagValueTooLong(1))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_invalid_tag_value(s *S3Conf) error {
|
||||
testName := "IAMTagRole_invalid_tag_value"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: iamTagList(map[string]string{"env": "invalid*value"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidTagValue(1))
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagRole_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 IAMTagRole_duplicate_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMTagRole_duplicate_tag_keys"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
for _, second := range []string{"env", "ENV"} {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
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 IAMTagRole_too_many_tags(s *S3Conf) error {
|
||||
testName := "IAMTagRole_too_many_tags"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_non_existing_role(s *S3Conf) error {
|
||||
testName := "IAMTagRole_non_existing_role"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName := "non-existing-" + genRandString(16)
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_tag_limit_exceeded(s *S3Conf) error {
|
||||
testName := "IAMTagRole_tag_limit_exceeded"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: numberedIAMTags(1, storage.MaxTagsPerResource),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"key1": "replaced"}),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("replacing a tag at the quota: %w", err)
|
||||
}
|
||||
|
||||
_, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"overflow": "x"}),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded))
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func IAMTagRole_success(s *S3Conf) error {
|
||||
testName := "IAMTagRole_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if 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 := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(want),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected TagRole response request id")
|
||||
}
|
||||
|
||||
if err := checkIAMRoleTags(client, roleName, want); err != nil {
|
||||
return err
|
||||
}
|
||||
// GetRole reports the same tags the tag actions maintain.
|
||||
role, err := getIAMRole(client, roleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return compareIAMTags(role.Role.Tags, want)
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagRole_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 IAMTagRole_overwrites_existing_tag(s *S3Conf) error {
|
||||
testName := "IAMTagRole_overwrites_existing_tag"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "staging"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMRoleTags(client, roleName, map[string]string{"env": "staging"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"ENV": "qa"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMRoleTags(client, roleName, map[string]string{"ENV": "qa"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMTagRole_isolated_from_same_named_user covers a role and a user sharing
|
||||
// a name: they are separate entities, so tagging one leaves the other's
|
||||
// tags untouched in both directions.
|
||||
func IAMTagRole_isolated_from_same_named_user(s *S3Conf) error {
|
||||
testName := "IAMTagRole_isolated_from_same_named_user"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
name := "shared-name-" + genRandString(16)
|
||||
if _, err := createIAMRole(client, &iam.CreateRoleInput{
|
||||
RoleName: &name,
|
||||
AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &name}); err != nil {
|
||||
deleteIAMRole(client, name)
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &name,
|
||||
Tags: iamTagList(map[string]string{"owner": "role"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tagIAMUser(client, &iam.TagUserInput{
|
||||
UserName: &name,
|
||||
Tags: iamTagList(map[string]string{"owner": "user"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkIAMRoleTags(client, name, map[string]string{"owner": "role"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMUserTags(client, name, map[string]string{"owner": "user"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Untagging the user must not strip the role's identically-named key.
|
||||
if _, err := untagIAMUser(client, &iam.UntagUserInput{UserName: &name, TagKeys: []string{"owner"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMUserTags(client, name, map[string]string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMRoleTags(client, name, map[string]string{"owner": "role"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMUser(client, name)
|
||||
if roleErr := deleteIAMRole(client, name); deleteErr == nil {
|
||||
deleteErr = roleErr
|
||||
}
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func tagIAMRole(client *iam.Client, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.TagRole(ctx, input)
|
||||
}
|
||||
|
||||
// createTaggableIAMRole creates a role carrying tags, if any, and returns
|
||||
// its generated name.
|
||||
func createTaggableIAMRole(client *iam.Client, tags []iamtypes.Tag) (string, error) {
|
||||
roleName := newIAMRoleName()
|
||||
_, err := createIAMRole(client, &iam.CreateRoleInput{
|
||||
RoleName: &roleName,
|
||||
AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument),
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return roleName, nil
|
||||
}
|
||||
|
||||
// checkIAMRoleTags asserts ListRoleTags reports exactly want for roleName.
|
||||
func checkIAMRoleTags(client *iam.Client, roleName string, want map[string]string) error {
|
||||
out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out.IsTruncated {
|
||||
return fmt.Errorf("expected IsTruncated to be false")
|
||||
}
|
||||
return compareIAMTags(out.Tags, want)
|
||||
}
|
||||
@@ -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.MaxTagsPerResource),
|
||||
}); 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<N>/value<N> 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
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// 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 IAMUntagOpenIDConnectProvider_missing_arn(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_missing_arn"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"UntagOpenIDConnectProvider"},
|
||||
"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("openIDConnectProviderArn"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagOpenIDConnectProvider_invalid_arn(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_invalid_arn"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
tests := []struct {
|
||||
name string
|
||||
arn string
|
||||
want iamerr.Error
|
||||
}{
|
||||
{"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)},
|
||||
{"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)},
|
||||
{"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")},
|
||||
{"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
_, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(tt.arn),
|
||||
TagKeys: []string{"env"},
|
||||
})
|
||||
if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil {
|
||||
return fmt.Errorf("%s: %w", tt.name, checkErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagOpenIDConnectProvider_missing_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_missing_tag_keys"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"UntagOpenIDConnectProvider"},
|
||||
"Version": {"2010-05-08"},
|
||||
"OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")},
|
||||
}.Encode())
|
||||
return authHandler(s, &authConfig{
|
||||
testName: testName,
|
||||
method: http.MethodPost,
|
||||
service: "iam",
|
||||
region: iamAuthRegion,
|
||||
body: body,
|
||||
date: time.Now().UTC(),
|
||||
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
}, func(req *http.Request) error {
|
||||
return checkIAMAuthRequest(s, req, iamerr.MissingValue("tagKeys"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagOpenIDConnectProvider_invalid_tag_key(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_invalid_tag_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://validprovider.example.com")
|
||||
for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} {
|
||||
_, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
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 IAMUntagOpenIDConnectProvider_too_many_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_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 := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://validprovider.example.com")),
|
||||
TagKeys: tagKeys,
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagOpenIDConnectProvider_non_existing_provider(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_non_existing_provider"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn := oidcProviderArn("https://" + genRandString(16) + ".example.com")
|
||||
_, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"env"},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagOpenIDConnectProvider_success(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"env", "owner"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected UntagOpenIDConnectProvider response request id")
|
||||
}
|
||||
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{"team": "storage"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagOpenIDConnectProvider_removal_is_idempotent covers the two ways a
|
||||
// request can name a key that removes nothing: a key the provider never
|
||||
// carried, and the same key twice in one request. Neither is an error —
|
||||
// unlike TagOpenIDConnectProvider, which rejects a repeated key outright.
|
||||
func IAMUntagOpenIDConnectProvider_removal_is_idempotent(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_removal_is_idempotent"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"never-existed"},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("removing a key the provider does not carry: %w", err)
|
||||
}
|
||||
if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"env", "env"},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("removing the same key twice: %w", err)
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagOpenIDConnectProvider_case_sensitive_key covers removal by a
|
||||
// differently-cased key: provider tag keys are compared exactly, so the
|
||||
// stored tag survives and only its exact key removes it.
|
||||
func IAMUntagOpenIDConnectProvider_case_sensitive_key(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_case_sensitive_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"EnV"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"env"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagOpenIDConnectProvider_removes_only_named_keys covers a partial
|
||||
// removal leaving the rest of the set intact, including a key whose name
|
||||
// only prefixes one of the supplied keys — matching is exact, not by prefix.
|
||||
func IAMUntagOpenIDConnectProvider_removes_only_named_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagOpenIDConnectProvider_removes_only_named_keys"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
arn, err := createTestOIDCProvider(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "environment": "prod", "team": "storage"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{
|
||||
OpenIDConnectProviderArn: &arn,
|
||||
TagKeys: []string{"env"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMOIDCProviderTags(client, arn, map[string]string{"environment": "prod", "team": "storage"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteOIDCProvider(client, arn)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func untagOIDCProvider(client *iam.Client, input *iam.UntagOpenIDConnectProviderInput) (*iam.UntagOpenIDConnectProviderOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.UntagOpenIDConnectProvider(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package 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 IAMUntagRole_missing_role_name(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_missing_role_name"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"UntagRole"},
|
||||
"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("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_invalid_role_name(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_invalid_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: aws.String("invalid role name"),
|
||||
TagKeys: []string{"env"},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.InvalidUserName("roleName"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_role_name_too_long(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_role_name_too_long"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: aws.String(strings.Repeat("a", 65)),
|
||||
TagKeys: []string{"env"},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_missing_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_missing_tag_keys"
|
||||
body := []byte(url.Values{
|
||||
"Action": {"UntagRole"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"validrolename"},
|
||||
}.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"))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_invalid_tag_key(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_invalid_tag_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} {
|
||||
_, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
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 IAMUntagRole_too_many_tag_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_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 := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: aws.String("validrolename"),
|
||||
TagKeys: tagKeys,
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_non_existing_role(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_non_existing_role"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName := "non-existing-" + genRandString(16)
|
||||
_, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"env"},
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName))
|
||||
})
|
||||
}
|
||||
|
||||
func IAMUntagRole_success(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_success"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"env", "owner"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
|
||||
return fmt.Errorf("expected UntagRole response request id")
|
||||
}
|
||||
|
||||
return checkIAMRoleTags(client, roleName, map[string]string{"team": "storage"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagRole_removal_is_idempotent covers the two ways a request can name
|
||||
// a key that removes nothing: a key the role never carried, and the same
|
||||
// key twice in one request. Neither is an error — unlike TagRole, which
|
||||
// rejects a repeated key outright.
|
||||
func IAMUntagRole_removal_is_idempotent(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_removal_is_idempotent"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"never-existed"},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("removing a key the role does not carry: %w", err)
|
||||
}
|
||||
if err := checkIAMRoleTags(client, roleName, map[string]string{"env": "prod"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"env", "env"},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("removing the same key twice: %w", err)
|
||||
}
|
||||
return checkIAMRoleTags(client, roleName, map[string]string{})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagRole_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 IAMUntagRole_case_insensitive_key(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_case_insensitive_key"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"EnV"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMRoleTags(client, roleName, map[string]string{})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
// IAMUntagRole_removes_only_named_keys covers a partial removal leaving the
|
||||
// rest of the set intact, including a key whose name only prefixes one of
|
||||
// the supplied keys — matching is exact, not by prefix.
|
||||
func IAMUntagRole_removes_only_named_keys(s *S3Conf) error {
|
||||
testName := "IAMUntagRole_removes_only_named_keys"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName, err := createTaggableIAMRole(client, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkErr := func() error {
|
||||
if _, err := tagIAMRole(client, &iam.TagRoleInput{
|
||||
RoleName: &roleName,
|
||||
Tags: iamTagList(map[string]string{"env": "prod", "environment": "prod", "team": "storage"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := untagIAMRole(client, &iam.UntagRoleInput{
|
||||
RoleName: &roleName,
|
||||
TagKeys: []string{"env"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkIAMRoleTags(client, roleName, map[string]string{"environment": "prod", "team": "storage"})
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func untagIAMRole(client *iam.Client, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
return client.UntagRole(ctx, input)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -88,10 +88,10 @@ func IAMUpdateAssumeRolePolicy_long_role_name(s *S3Conf) error {
|
||||
testName := "IAMUpdateAssumeRolePolicy_long_role_name"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
_, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{
|
||||
RoleName: aws.String(strings.Repeat("a", 129)),
|
||||
RoleName: aws.String(strings.Repeat("a", 65)),
|
||||
PolicyDocument: aws.String(validTrustPolicyDocument),
|
||||
})
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128))
|
||||
return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+81
-12
@@ -219,7 +219,7 @@ under the License.
|
||||
<button type="button" onclick="iamAddTagRow('create-provider-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
|
||||
</div>
|
||||
<div id="create-provider-tags" class="space-y-2"></div>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the provider’s Manage view.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -239,7 +239,7 @@ under the License.
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||||
<div>
|
||||
<h2 id="manage-provider-title" class="text-xl font-semibold text-charcoal">Provider</h2>
|
||||
<p class="text-sm text-charcoal-300 mt-1">URL and tags are fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.</p>
|
||||
<p class="text-sm text-charcoal-300 mt-1">The URL is fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.</p>
|
||||
</div>
|
||||
<button onclick="closeModal('manage-provider-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -263,13 +263,21 @@ under the License.
|
||||
<dt class="text-charcoal-300">Created</dt>
|
||||
<dd id="provider-detail-created" class="mt-1 text-charcoal">-</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-charcoal-300">Tags</dt>
|
||||
<dd id="provider-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-charcoal">Tags</h3>
|
||||
<p id="provider-tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
|
||||
</div>
|
||||
<button id="edit-provider-tags-btn" onclick="openProviderTagEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Tags</button>
|
||||
</div>
|
||||
<div id="provider-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Client IDs -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-charcoal mb-1">Audiences / Client IDs</h3>
|
||||
@@ -360,6 +368,7 @@ under the License.
|
||||
<script>
|
||||
let allProviders = []; // ARNs
|
||||
let currentProvider = null; // { arn, url, clientIDList, thumbprintList, createDate, tags }
|
||||
let currentProviderTags = []; // the open provider's tags, as [{Key, Value}]
|
||||
let providerToDelete = null;
|
||||
|
||||
if (!requireIAM()) {
|
||||
@@ -506,14 +515,15 @@ under the License.
|
||||
|
||||
async function openManageProviderModal(arn) {
|
||||
currentProvider = { arn };
|
||||
currentProviderTags = [];
|
||||
document.getElementById('manage-provider-title').textContent = providerNameFromArn(arn);
|
||||
document.getElementById('provider-detail-arn').innerHTML = iamArnCell(arn);
|
||||
document.getElementById('provider-detail-url').textContent = 'Loading...';
|
||||
document.getElementById('provider-detail-created').textContent = '-';
|
||||
document.getElementById('provider-detail-tags').innerHTML = '<span class="text-charcoal-300">-</span>';
|
||||
document.getElementById('provider-clients').innerHTML = '';
|
||||
document.getElementById('provider-thumbprints').innerHTML = '';
|
||||
openModal('manage-provider-modal');
|
||||
loadProviderTags();
|
||||
await loadProviderDetail();
|
||||
}
|
||||
|
||||
@@ -524,11 +534,6 @@ under the License.
|
||||
document.getElementById('provider-detail-url').textContent = detail.url || '-';
|
||||
document.getElementById('provider-detail-created').textContent = iamFormatDate(detail.createDate);
|
||||
|
||||
const tagsEl = document.getElementById('provider-detail-tags');
|
||||
tagsEl.innerHTML = detail.tags.length === 0
|
||||
? '<span class="text-charcoal-300">-</span>'
|
||||
: detail.tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
|
||||
|
||||
renderClientIds(detail.clientIDList);
|
||||
renderThumbprints(detail.thumbprintList);
|
||||
} catch (error) {
|
||||
@@ -540,6 +545,70 @@ under the License.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Manage: tags
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ListOpenIDConnectProviderTags is its own permission, so this loads the
|
||||
* tags rather than reusing whatever GetOpenIDConnectProvider happened to
|
||||
* return — and a denial disables editing in place instead of failing the
|
||||
* whole modal.
|
||||
*/
|
||||
async function loadProviderTags() {
|
||||
const el = document.getElementById('provider-tags');
|
||||
el.innerHTML = '<span class="text-sm text-charcoal-300">Loading...</span>';
|
||||
try {
|
||||
currentProviderTags = [];
|
||||
let marker = null;
|
||||
do {
|
||||
const page = await api.iamListOIDCProviderTags(currentProvider.arn, { marker: marker || undefined });
|
||||
currentProviderTags = currentProviderTags.concat(page.tags);
|
||||
marker = page.isTruncated ? page.marker : null;
|
||||
} while (marker);
|
||||
|
||||
el.innerHTML = iamTagChips(currentProviderTags);
|
||||
setEditProviderTagsEnabled(true);
|
||||
updateProviderTagQuotaNote();
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
setEditProviderTagsEnabled(false);
|
||||
el.innerHTML = iamIsAccessDenied(error)
|
||||
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this provider\u2019s tags</span>'
|
||||
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditProviderTagsEnabled(enabled) {
|
||||
const button = document.getElementById('edit-provider-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 updateProviderTagQuotaNote() {
|
||||
document.getElementById('provider-tag-quota-note').textContent =
|
||||
`${currentProviderTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Keys are case sensitive.`;
|
||||
}
|
||||
|
||||
function openProviderTagEditor() {
|
||||
iamTagEditor.open({
|
||||
title: 'Edit Tags',
|
||||
subtitle: `Provider ${providerNameFromArn(currentProvider.arn)}`,
|
||||
tags: currentProviderTags,
|
||||
caseSensitiveKeys: true,
|
||||
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.iamUntagOIDCProvider(currentProvider.arn, remove);
|
||||
if (set.length) await api.iamTagOIDCProvider(currentProvider.arn, set);
|
||||
showToast('Tags updated successfully', 'success');
|
||||
loadProviderTags();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderClientIds(clients) {
|
||||
const el = document.getElementById('provider-clients');
|
||||
if (!clients || clients.length === 0) {
|
||||
|
||||
+78
-11
@@ -230,7 +230,7 @@ under the License.
|
||||
<button type="button" onclick="iamAddTagRow('create-role-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
|
||||
</div>
|
||||
<div id="create-role-tags" class="space-y-2"></div>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the role’s Manage view.</p>
|
||||
</div>
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
|
||||
@@ -302,13 +302,21 @@ under the License.
|
||||
<dt class="text-charcoal-300" title="Set at creation, not editable">Description</dt>
|
||||
<dd id="role-detail-description" class="mt-1 text-charcoal">-</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-charcoal-300">Tags</dt>
|
||||
<dd id="role-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-charcoal">Tags</h3>
|
||||
<p id="role-tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
|
||||
</div>
|
||||
<button id="edit-role-tags-btn" onclick="openRoleTagEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Tags</button>
|
||||
</div>
|
||||
<div id="role-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Trust policy -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -382,6 +390,7 @@ under the License.
|
||||
let nextMarker = null;
|
||||
let currentRole = null;
|
||||
let rolePolicySizes = {};
|
||||
let currentRoleTags = []; // the open role's tags, as [{Key, Value}]
|
||||
let roleToDelete = null;
|
||||
let pendingRoleDetails = null; // step 1 of the create wizard
|
||||
|
||||
@@ -559,10 +568,12 @@ under the License.
|
||||
async function openManageRoleModal(roleName) {
|
||||
currentRole = allRoles.find(r => r.RoleName === roleName) || { RoleName: roleName };
|
||||
rolePolicySizes = {};
|
||||
currentRoleTags = [];
|
||||
|
||||
document.getElementById('manage-role-title').textContent = roleName;
|
||||
renderRoleDetails(currentRole);
|
||||
openModal('manage-role-modal');
|
||||
loadRoleTags();
|
||||
|
||||
// Refresh from the server so the trust document is current
|
||||
try {
|
||||
@@ -586,12 +597,6 @@ under the License.
|
||||
: '-';
|
||||
document.getElementById('role-detail-description').textContent = role.Description || '-';
|
||||
|
||||
const tagsEl = document.getElementById('role-detail-tags');
|
||||
const tags = Array.isArray(role.Tags) ? role.Tags : (role.Tags ? [role.Tags] : []);
|
||||
tagsEl.innerHTML = tags.length === 0
|
||||
? '<span class="text-charcoal-300">-</span>'
|
||||
: tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
|
||||
|
||||
const preview = document.getElementById('role-trust-preview');
|
||||
const trust = role.AssumeRolePolicyDocument || '';
|
||||
if (!trust) {
|
||||
@@ -605,6 +610,68 @@ under the License.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Manage: tags
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ListRoleTags is its own permission, so this loads the tags rather than
|
||||
* reusing whatever GetRole happened to return — and a denial disables
|
||||
* editing in place instead of failing the whole modal.
|
||||
*/
|
||||
async function loadRoleTags() {
|
||||
const el = document.getElementById('role-tags');
|
||||
el.innerHTML = '<span class="text-sm text-charcoal-300">Loading...</span>';
|
||||
try {
|
||||
currentRoleTags = [];
|
||||
let marker = null;
|
||||
do {
|
||||
const page = await api.iamListRoleTags(currentRole.RoleName, { marker: marker || undefined });
|
||||
currentRoleTags = currentRoleTags.concat(page.tags);
|
||||
marker = page.isTruncated ? page.marker : null;
|
||||
} while (marker);
|
||||
|
||||
el.innerHTML = iamTagChips(currentRoleTags);
|
||||
setEditRoleTagsEnabled(true);
|
||||
updateRoleTagQuotaNote();
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
setEditRoleTagsEnabled(false);
|
||||
el.innerHTML = iamIsAccessDenied(error)
|
||||
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this role\u2019s tags</span>'
|
||||
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditRoleTagsEnabled(enabled) {
|
||||
const button = document.getElementById('edit-role-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 updateRoleTagQuotaNote() {
|
||||
document.getElementById('role-tag-quota-note').textContent =
|
||||
`${currentRoleTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Also readable from policy conditions.`;
|
||||
}
|
||||
|
||||
function openRoleTagEditor() {
|
||||
iamTagEditor.open({
|
||||
title: 'Edit Tags',
|
||||
subtitle: `Role ${currentRole.RoleName}`,
|
||||
tags: currentRoleTags,
|
||||
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.iamUntagRole(currentRole.RoleName, remove);
|
||||
if (set.length) await api.iamTagRole(currentRole.RoleName, set);
|
||||
showToast('Tags updated successfully', 'success');
|
||||
loadRoleTags();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openTrustPolicyEditor() {
|
||||
if (!currentRole) return;
|
||||
let documentText = currentRole.AssumeRolePolicyDocument || '';
|
||||
|
||||
+75
-15
@@ -211,7 +211,7 @@ under the License.
|
||||
<button type="button" onclick="iamAddTagRow('create-user-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
|
||||
</div>
|
||||
<div id="create-user-tags" class="space-y-2"></div>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only. This API has no tag-update action, so they are read-only afterwards.</p>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the user’s Manage view.</p>
|
||||
</div>
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
|
||||
@@ -275,13 +275,21 @@ under the License.
|
||||
<dt class="text-charcoal-300">Created</dt>
|
||||
<dd id="detail-created" class="mt-1 text-charcoal">-</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-charcoal-300">Tags</dt>
|
||||
<dd id="detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-charcoal">Tags</h3>
|
||||
<p id="tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
|
||||
</div>
|
||||
<button id="edit-tags-btn" onclick="openUserTagEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Tags</button>
|
||||
</div>
|
||||
<div id="user-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Access Keys -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -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 = '<span class="text-charcoal-300">-</span>';
|
||||
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 = '<span class="text-sm text-charcoal-300">Loading...</span>';
|
||||
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)
|
||||
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this user\u2019s tags</span>'
|
||||
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
|
||||
}
|
||||
el.innerHTML = list.map(tag =>
|
||||
`<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`
|
||||
).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() {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
/**
|
||||
@@ -2601,6 +2631,36 @@ ${tagsXml}
|
||||
await this.iamRequest('UpdateAssumeRolePolicy', { RoleName: roleName, PolicyDocument: policyDocument });
|
||||
}
|
||||
|
||||
// ---- Role tags ----
|
||||
|
||||
/**
|
||||
* Add or replace tags on a role. A key already present is overwritten
|
||||
* rather than duplicated, so this doubles as the edit path.
|
||||
*/
|
||||
async iamTagRole(roleName, tags) {
|
||||
const params = { RoleName: roleName };
|
||||
this.flattenTags(params, tags);
|
||||
await this.iamRequest('TagRole', params);
|
||||
}
|
||||
|
||||
async iamUntagRole(roleName, tagKeys) {
|
||||
const params = { RoleName: roleName };
|
||||
this.flattenMemberList(params, 'TagKeys', tagKeys);
|
||||
await this.iamRequest('UntagRole', params);
|
||||
}
|
||||
|
||||
async iamListRoleTags(roleName, options = {}) {
|
||||
const params = { RoleName: roleName };
|
||||
if (options.marker) params.Marker = options.marker;
|
||||
if (options.maxItems) params.MaxItems = options.maxItems;
|
||||
const result = await this.iamRequest('ListRoleTags', params);
|
||||
return {
|
||||
tags: iamAsArray(result.Tags),
|
||||
isTruncated: result.IsTruncated === 'true',
|
||||
marker: result.Marker || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles echo their trust policy percent-encoded on create/get/list
|
||||
*/
|
||||
@@ -2698,6 +2758,38 @@ ${tagsXml}
|
||||
await this.iamRequest('UpdateOpenIDConnectProviderThumbprint', params);
|
||||
}
|
||||
|
||||
// ---- OIDC provider tags ----
|
||||
|
||||
/**
|
||||
* Add or replace tags on an OIDC provider. A key already present is
|
||||
* overwritten rather than duplicated, so this doubles as the edit path.
|
||||
* Provider tag keys are compared exactly, so a key differing only in case
|
||||
* is a separate tag.
|
||||
*/
|
||||
async iamTagOIDCProvider(arn, tags) {
|
||||
const params = { OpenIDConnectProviderArn: arn };
|
||||
this.flattenTags(params, tags);
|
||||
await this.iamRequest('TagOpenIDConnectProvider', params);
|
||||
}
|
||||
|
||||
async iamUntagOIDCProvider(arn, tagKeys) {
|
||||
const params = { OpenIDConnectProviderArn: arn };
|
||||
this.flattenMemberList(params, 'TagKeys', tagKeys);
|
||||
await this.iamRequest('UntagOpenIDConnectProvider', params);
|
||||
}
|
||||
|
||||
async iamListOIDCProviderTags(arn, options = {}) {
|
||||
const params = { OpenIDConnectProviderArn: arn };
|
||||
if (options.marker) params.Marker = options.marker;
|
||||
if (options.maxItems) params.MaxItems = options.maxItems;
|
||||
const result = await this.iamRequest('ListOpenIDConnectProviderTags', params);
|
||||
return {
|
||||
tags: iamAsArray(result.Tags),
|
||||
isTruncated: result.IsTruncated === 'true',
|
||||
marker: result.Marker || null
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Self identity (STS) ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,60 @@ 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. caseSensitiveKeys
|
||||
* selects the resource's key-comparison rule: users and roles fold key
|
||||
* case, OIDC providers compare keys exactly. Returns an error string, or
|
||||
* null when the list is acceptable.
|
||||
*/
|
||||
function iamValidateTags(tags, caseSensitiveKeys = false) {
|
||||
if (tags.length > IAM_LIMITS.tagsPerResource) {
|
||||
return `A single resource 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.`;
|
||||
}
|
||||
const normalized = caseSensitiveKeys ? tag.Key : tag.Key.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
return caseSensitiveKeys
|
||||
? `Tag key "${tag.Key}" is listed twice.`
|
||||
: `Tag key "${tag.Key}" is listed twice. Keys are case insensitive.`;
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tag list as read-only two-tone chips, key alongside value.
|
||||
*/
|
||||
function iamTagChips(tags) {
|
||||
if (!tags.length) return '<span class="text-sm text-charcoal-300">No tags</span>';
|
||||
return tags.map(tag => {
|
||||
const value = tag.Value
|
||||
? `<span class="px-2 py-1 bg-white border-l border-gray-200 text-charcoal-400">${escapeHtml(tag.Value)}</span>`
|
||||
: '<span class="px-2 py-1 bg-white border-l border-gray-200 text-charcoal-300 italic">empty</span>';
|
||||
return `<span class="inline-flex items-center overflow-hidden rounded-md border border-gray-200 bg-gray-50 text-xs font-mono">
|
||||
<span class="px-2 py-1 font-medium text-charcoal">${escapeHtml(tag.Key)}</span>${value}
|
||||
</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Repeatable form rows (tags, client IDs, thumbprints)
|
||||
// ============================================
|
||||
@@ -639,3 +695,197 @@ const iamPolicyEditor = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Tag editor
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Edit an identity's whole tag set at once, then apply it as the minimal
|
||||
* pair of API calls: one untag for the keys that disappeared, one tag 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 = `<div class="modal-backdrop absolute inset-0" onclick="iamTagEditor.close()"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
|
||||
<div>
|
||||
<h2 id="iam-tag-title" class="text-xl font-semibold text-charcoal">Tags</h2>
|
||||
<p id="iam-tag-subtitle" class="text-sm text-charcoal-300 mt-1"></p>
|
||||
</div>
|
||||
<button onclick="iamTagEditor.close()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="p-6 space-y-5">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div class="text-sm text-blue-800">
|
||||
<p class="font-medium">About Tags</p>
|
||||
<p id="iam-tag-about" class="mt-1"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="iam-tag-status" class="hidden"></div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-charcoal">Tags</label>
|
||||
<button type="button" onclick="iamTagEditor.addRow()" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
|
||||
</div>
|
||||
<div id="iam-tag-rows" class="space-y-2"></div>
|
||||
<p id="iam-tag-empty" class="hidden py-6 text-center text-sm text-charcoal-300">No tags yet. Use <span class="font-medium">Add Tag</span> to create one.</p>
|
||||
<p id="iam-tag-counter" class="mt-3 text-xs text-charcoal-300"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
|
||||
<button onclick="iamTagEditor.close()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||||
<button id="iam-tag-save-btn" onclick="iamTagEditor.save()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Save Tags</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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 resource these tags belong to
|
||||
* tags current tags, as [{Key, Value}]
|
||||
* caseSensitiveKeys compare keys exactly instead of folding their case
|
||||
* 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 || '';
|
||||
document.getElementById('iam-tag-about').innerHTML =
|
||||
'Key/value labels for grouping and search. They are also readable from policy conditions as ' +
|
||||
'<code class="font-mono">aws:PrincipalTag/<key></code> for the tagged identity and ' +
|
||||
'<code class="font-mono">aws:ResourceTag/<key></code> for the resource being acted on. ' +
|
||||
(opts.caseSensitiveKeys
|
||||
? 'Keys are case sensitive, so "env" and "ENV" are separate tags; values may be empty.'
|
||||
: 'Keys are case insensitive; values may be empty.');
|
||||
|
||||
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. Where keys
|
||||
* fold, a key whose only change is its casing still lands in set: the tag
|
||||
* action overwrites the stored tag in place, taking the new casing with
|
||||
* it. Where keys are exact, that same edit is a removal plus an addition.
|
||||
*/
|
||||
_diff(current) {
|
||||
const original = this._state.tags || [];
|
||||
const key = tag => (this._state.caseSensitiveKeys ? tag.Key : tag.Key.toLowerCase());
|
||||
const originalByKey = new Map(original.map(tag => [key(tag), tag]));
|
||||
const currentKeys = new Set(current.map(key));
|
||||
|
||||
const set = current.filter(tag => {
|
||||
const before = originalByKey.get(key(tag));
|
||||
return !before || before.Key !== tag.Key || (before.Value || '') !== (tag.Value || '');
|
||||
});
|
||||
const remove = original
|
||||
.filter(tag => !currentKeys.has(key(tag)))
|
||||
.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, state.caseSensitiveKeys);
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user