feat: add IAM OIDC provider tagging actions

Adds `TagOpenIDConnectProvider`, `UntagOpenIDConnectProvider` and `ListOpenIDConnectProviderTags` to the standalone IAM service, backed by both the internal and Vault storers. They follow the user and role tagging actions in most respects — the tag action merges into the provider's existing tags and rejects a repeated key, untag removal is idempotent, and the tag listing is sorted by key and paginated, with the per-request member count and the per-provider tag total enforced as separate quotas so replacing a tag on a provider already at the 50-tag cap still succeeds — but differ in the one respect IAM itself draws: OIDC provider tag keys are compared exactly, not case-insensitively. On a provider `env` and `ENV` are two independent tags, both may be supplied in a single request, only a byte-identical repeat is a duplicate (reported without the "Tag keys are case insensitive" note the user and role actions carry), and untagging `env` leaves `ENV` in place.

That distinction is now carried by `iamutil.TagKeyCase`, which `ParseTags` uses for duplicate detection and which `mergeTags`, `removeTags` and the tag listing's marker lookup use for key matching. `CreateOpenIDConnectProvider` moves onto the exact comparison too, so a provider created with case-differing tag keys keeps both.

All three actions are authorized against the target provider's ARN, so `aws:ResourceTag/<key>` reads the provider's own tags, and the tag and untag actions populate `aws:RequestTag/<key>` and `aws:TagKeys` respectively, so a tag-scoped policy Condition governs which tags a caller may set or remove. All three report a missing provider with the wording `DeleteOpenIDConnectProvider` uses rather than the one `GetOpenIDConnectProvider` uses, which is why the Vault provider read now takes the not-found error its calling action reports.

The WebGUI gains a Tags section in the OIDC provider manage view, replacing the read-only tag row, and the shared tag editor gains a case-sensitive mode that changes its duplicate-key check, its diffing of an edited set into an untag and tag pair, and the wording of its guidance.
This commit is contained in:
niksis02
2026-08-28 00:49:21 +04:00
parent 1bbcd64195
commit 4901afe27b
19 changed files with 2086 additions and 94 deletions
+88 -5
View File
@@ -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
}
@@ -244,7 +244,7 @@ func (c IAMApiController) TagUser(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
}
@@ -662,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
}
@@ -826,7 +826,7 @@ func (c IAMApiController) TagRole(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
}
@@ -1069,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
}
@@ -1214,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 == "" {
+454 -2
View File
@@ -2868,6 +2868,8 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) {
message: "Thumbprint list must contain fewer than 5 entries.",
},
{
// Provider tag keys are compared exactly, so only a
// byte-identical repeat is a duplicate.
name: "duplicate tag keys",
params: url.Values{
"Action": {"CreateOpenIDConnectProvider"},
@@ -2875,12 +2877,12 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) {
"ThumbprintList.member.1": {strings.Repeat("a", 40)},
"Tags.member.1.Key": {"key"},
"Tags.member.1.Value": {"one"},
"Tags.member.2.Key": {"KEY"},
"Tags.member.2.Key": {"key"},
"Tags.member.2.Value": {"two"},
},
status: http.StatusBadRequest,
code: "InvalidInput",
message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
message: "Duplicate tag keys found.",
},
}
@@ -2893,6 +2895,456 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) {
}
}
// TestIAMApiControllerOIDCProviderTagLifecycle covers the provider tagging
// actions' distinguishing trait: tag keys are compared exactly, so "env"
// and "ENV" are two independent tags rather than one.
func TestIAMApiControllerOIDCProviderTagLifecycle(t *testing.T) {
server := newIAMControllerTestServer(t)
arn := createTestOIDCProviderForTrust(t, server, "https://tags.example.com", "")
if got := listOIDCProviderTags(t, server, arn); len(got.Tags.Members) != 0 || got.IsTruncated {
t.Fatalf("ListOpenIDConnectProviderTags on a fresh provider = %#v, want no tags", got)
}
tagOIDCProvider(t, server, arn, map[string]string{"env": "prod", "team": "storage", "empty": ""})
got := listOIDCProviderTags(t, server, arn)
// Sorted by key, regardless of the order they were added in.
want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want)
}
// A repeated key replaces its value in place; a differently-cased key
// is a different tag and is added alongside.
tagOIDCProvider(t, server, arn, map[string]string{"env": "staging"})
tagOIDCProvider(t, server, arn, map[string]string{"TEAM": "compute"})
got = listOIDCProviderTags(t, server, arn)
want = []iamtypes.Tag{
{Key: "TEAM", Value: "compute"},
{Key: "empty", Value: ""},
{Key: "env", Value: "staging"},
{Key: "team", Value: "storage"},
}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want)
}
// GetOpenIDConnectProvider reports the same tags the tag actions maintain.
get := doIAMAction(t, server, url.Values{"Action": {"GetOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}})
var getOut iamtypes.GetOpenIDConnectProviderResponse
unmarshalXML(t, readBody(t, get), &getOut)
if len(getOut.Result.Tags) != 4 {
t.Fatalf("GetOpenIDConnectProvider Tags = %#v, want 4 tags", getOut.Result.Tags)
}
// Removal matches keys exactly, and a key naming no tag is not an error.
untag := doIAMAction(t, server, url.Values{
"Action": {"UntagOpenIDConnectProvider"},
"OpenIDConnectProviderArn": {arn},
"TagKeys.member.1": {"team"},
"TagKeys.member.2": {"EnV"},
"TagKeys.member.3": {"never-existed"},
})
if untag.StatusCode != http.StatusOK {
t.Fatalf("UntagOpenIDConnectProvider status = %d, body=%s", untag.StatusCode, readBody(t, untag))
}
got = listOIDCProviderTags(t, server, arn)
want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want)
}
}
// TestIAMApiControllerOIDCProviderTagsIndependentPerProvider covers two
// providers carrying the same tag keys: each set is its own.
func TestIAMApiControllerOIDCProviderTagsIndependentPerProvider(t *testing.T) {
server := newIAMControllerTestServer(t)
first := createTestOIDCProviderForTrust(t, server, "https://first.example.com", "")
second := createTestOIDCProviderForTrust(t, server, "https://second.example.com", "")
tagOIDCProvider(t, server, first, map[string]string{"owner": "first"})
tagOIDCProvider(t, server, second, map[string]string{"owner": "second"})
untag := doIAMAction(t, server, url.Values{
"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {first}, "TagKeys.member.1": {"owner"},
})
if untag.StatusCode != http.StatusOK {
t.Fatalf("UntagOpenIDConnectProvider status = %d, body=%s", untag.StatusCode, readBody(t, untag))
}
if got := listOIDCProviderTags(t, server, first).Tags.Members; len(got) != 0 {
t.Fatalf("first provider tags after untag = %#v, want none", got)
}
if got := listOIDCProviderTags(t, server, second).Tags.Members; !slices.Equal(got, []iamtypes.Tag{{Key: "owner", Value: "second"}}) {
t.Fatalf("second provider tags = %#v", got)
}
}
func TestIAMApiControllerListOIDCProviderTagsPagination(t *testing.T) {
server := newIAMControllerTestServer(t)
arn := createTestOIDCProviderForTrust(t, server, "https://paged.example.com", "")
tagOIDCProvider(t, server, arn, map[string]string{"a": "1", "b": "2", "c": "3"})
var seen []iamtypes.Tag
marker := ""
for page := 1; ; page++ {
params := url.Values{
"Action": {"ListOpenIDConnectProviderTags"},
"OpenIDConnectProviderArn": {arn},
"MaxItems": {"1"},
}
if marker != "" {
params.Set("Marker", marker)
}
resp := doIAMAction(t, server, params)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ListOpenIDConnectProviderTags status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out struct {
Result iamtypes.ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"`
}
unmarshalXML(t, readBody(t, resp), &out)
if len(out.Result.Tags.Members) != 1 {
t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members))
}
seen = append(seen, out.Result.Tags.Members...)
if !out.Result.IsTruncated {
if out.Result.Marker != "" {
t.Fatalf("final page Marker = %q, want empty", out.Result.Marker)
}
break
}
marker = out.Result.Marker
}
want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}}
if !slices.Equal(seen, want) {
t.Fatalf("paged tags = %#v, want %#v", seen, want)
}
}
func TestIAMApiControllerTagOIDCProviderExceedsQuota(t *testing.T) {
server := newIAMControllerTestServer(t)
arn := createTestOIDCProviderForTrust(t, server, "https://quota.example.com", "")
atQuota := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}
for i := 1; i <= storage.MaxTagsPerResource; i++ {
atQuota.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i))
atQuota.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i))
}
if resp := doIAMAction(t, server, atQuota); resp.StatusCode != http.StatusOK {
t.Fatalf("TagOpenIDConnectProvider with %d tags status = %d, body=%s", storage.MaxTagsPerResource, resp.StatusCode, readBody(t, resp))
}
// Replacing an existing key at the quota is fine: the total doesn't grow.
replace := doIAMAction(t, server, url.Values{
"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn},
"Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"},
})
if replace.StatusCode != http.StatusOK {
t.Fatalf("TagOpenIDConnectProvider replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace))
}
// A differently-cased key is a new tag, so it overflows the quota.
overflow := doIAMAction(t, server, url.Values{
"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn},
"Tags.member.1.Key": {"K1"}, "Tags.member.1.Value": {"x"},
})
requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded", "The number of tags has reached the maximum limit.")
}
func TestIAMApiControllerOIDCProviderTagValidationErrors(t *testing.T) {
const arn = "arn:aws:iam::000000000000:oidc-provider/tags.example.com"
tooManyTags := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}
for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ {
tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i))
tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i))
}
tooManyTagKeys := url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}
for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ {
tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i))
}
const missingArn = "arn:aws:iam::000000000000:oidc-provider/nosuch.example.com"
const notFoundMessage = "OpenId connect Provider " + missingArn + " cannot be found."
tests := []struct {
name string
setupProvider bool
params url.Values
status int
code string
message string
}{
{
name: "tag missing arn",
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null",
},
{
name: "tag arn too short",
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {"arn:aws:iam::1"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must have length greater than or equal to 20",
},
{
name: "tag wrong resource type in arn",
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:user/alice"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "Invalid resource type in ARN",
},
{
name: "tag missing tags",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null",
},
{
name: "tag missing key",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null",
},
{
name: "tag missing value",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"env"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null",
},
{
name: "tag empty key",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1",
},
{
name: "tag key too long",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128",
},
{
name: "tag invalid key characters",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+",
},
{
name: "tag value too long",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256",
},
{
name: "tag invalid value characters",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*",
},
{
name: "tag duplicate keys",
setupProvider: true,
params: url.Values{
"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn},
"Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"},
"Tags.member.2.Key": {"env"}, "Tags.member.2.Value": {"staging"},
},
status: http.StatusBadRequest,
code: "InvalidInput",
message: "Duplicate tag keys found.",
},
{
name: "tag too many tags",
setupProvider: true,
params: tooManyTags,
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50",
},
{
name: "tag invalid tag on non existing provider",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+",
},
{
name: "tag non existing provider",
setupProvider: true,
params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: notFoundMessage,
},
{
name: "untag missing arn",
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "TagKeys.member.1": {"env"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null",
},
{
name: "untag missing tag keys",
setupProvider: true,
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null",
},
{
name: "untag empty tag key",
setupProvider: true,
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {""}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag tag key too long",
setupProvider: true,
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {strings.Repeat("k", 129)}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag invalid tag key",
setupProvider: true,
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {"bad*key"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag too many tag keys",
setupProvider: true,
params: tooManyTagKeys,
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50",
},
{
name: "untag non existing provider",
setupProvider: true,
params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "TagKeys.member.1": {"env"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: notFoundMessage,
},
{
name: "list missing arn",
params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null",
},
{
name: "list max items too low",
setupProvider: true,
params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"0"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1",
},
{
name: "list max items too high",
setupProvider: true,
params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"1001"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000",
},
{
name: "list max items not a number",
setupProvider: true,
params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"abc"}},
status: http.StatusBadRequest,
code: "MalformedInput",
message: "",
},
{
name: "list non existing provider",
setupProvider: true,
params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {missingArn}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: notFoundMessage,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := newIAMControllerTestServer(t)
if tt.setupProvider {
createTestOIDCProviderForTrust(t, server, "https://tags.example.com", "")
}
resp := doIAMAction(t, server, tt.params)
requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
})
}
}
func tagOIDCProvider(t *testing.T, server *IAMApiServer, arn string, tags map[string]string) {
t.Helper()
params := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}
i := 1
for key, value := range tags {
params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key)
params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value)
i++
}
resp := doIAMAction(t, server, params)
if resp.StatusCode != http.StatusOK {
t.Fatalf("TagOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
func listOIDCProviderTags(t *testing.T, server *IAMApiServer, arn string) iamtypes.ListOpenIDConnectProviderTagsResult {
t.Helper()
resp := doIAMAction(t, server, url.Values{
"Action": {"ListOpenIDConnectProviderTags"},
"OpenIDConnectProviderArn": {arn},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("ListOpenIDConnectProviderTags status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out struct {
Result iamtypes.ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"`
}
unmarshalXML(t, readBody(t, resp), &out)
return out.Result
}
func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) {
store, err := storage.New(storage.Config{Dir: t.TempDir()})
if err != nil {
+10
View File
@@ -62,6 +62,7 @@ const (
ErrTagLimitExceeded
ErrInvalidPathPrefix
ErrDuplicateTagKeys
ErrDuplicateExactTagKeys
ErrInvalidAccessKeyIDChars
ErrDeleteConflict
ErrDeleteConflictPolicies
@@ -262,6 +263,12 @@ var errorCodeResponse = map[ErrorCode]Error{
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",
@@ -540,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)
}
+9 -6
View File
@@ -186,7 +186,8 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string
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
@@ -382,9 +383,11 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
}
switch action {
case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider", "TagUser", "TagRole":
addRequestTagContext(condCtx, ctx)
case "UntagUser", "UntagRole":
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)
}
@@ -459,8 +462,8 @@ func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) {
// the same parse independently and will reject the request with the
// specific tag-validation error afterward, so no write can succeed with
// tags that silently evaded a tag-scoped Condition.
func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) {
tags, err := iamutil.ParseTags(ctx)
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
}
+43 -6
View File
@@ -202,10 +202,47 @@ func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) {
return int32(parsed), nil
}
// 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. Tag keys
// are compared case-insensitively for duplicate detection, matching AWS.
func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
// 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{}{}
@@ -234,10 +271,10 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
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{}{}
@@ -247,7 +284,7 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
return tags, nil
}
// ParseTagKeys reads UntagUser's TagKeys members from the request (up to
// 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.
+4
View File
@@ -103,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,
+24 -17
View File
@@ -21,6 +21,7 @@ import (
"time"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/internal/iamutil"
"github.com/versity/versitygw/iamapi/types"
)
@@ -28,8 +29,8 @@ import (
// user may hold at once, matching the AWS IAM quota.
const MaxAccessKeysPerUser = 2
// MaxTagsPerResource is the maximum number of tags a single IAM user or
// role may carry at once, matching the AWS IAM quota.
// 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
@@ -200,16 +201,22 @@ 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
// case-insensitively — taking over its position and its key's casing — and
// any remaining incoming tag is appended in the order supplied. AWS caps
// the merged total, not the request, so replacing a tag on a resource
// already at the cap is allowed.
func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) {
// 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); idx >= 0 {
if idx := indexOfTagKey(merged, tag.Key, keyCase); idx >= 0 {
merged[idx] = tag
continue
}
@@ -222,19 +229,19 @@ func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) {
}
// removeTags applies the untag actions' removal semantics to existing:
// every tag whose key case-insensitively matches one of tagKeys is dropped,
// and a key naming no existing tag is ignored rather than reported.
func removeTags(existing []types.Tag, tagKeys []string) []types.Tag {
// 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 strings.EqualFold(key, tag.Key)
return keyCase.Equal(key, tag.Key)
})
})
}
func indexOfTagKey(tags []types.Tag, key string) int {
func indexOfTagKey(tags []types.Tag, key string, keyCase iamutil.TagKeyCase) int {
return slices.IndexFunc(tags, func(tag types.Tag) bool {
return strings.EqualFold(tag.Key, key)
return keyCase.Equal(tag.Key, key)
})
}
@@ -243,7 +250,7 @@ func indexOfTagKey(tags []types.Tag, key string) int {
// 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) *ListTagsOutput {
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)
@@ -251,7 +258,7 @@ func paginateTags(tags []types.Tag, marker string, maxItems int32) *ListTagsOutp
if marker != "" {
start := len(sorted)
if idx := indexOfTagKey(sorted, marker); idx >= 0 {
if idx := indexOfTagKey(sorted, marker, keyCase); idx >= 0 {
start = idx + 1
}
sorted = sorted[start:]
+74 -6
View File
@@ -349,7 +349,7 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t
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)
merged, err := mergeTags(user.Tags, tags, iamutil.TagKeysFolded)
if err != nil {
return err
}
@@ -360,7 +360,7 @@ func (s *InternalStore) TagUser(_ context.Context, userName string, tags []types
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)
user.Tags = removeTags(user.Tags, tagKeys, iamutil.TagKeysFolded)
return nil
})
}
@@ -405,7 +405,7 @@ func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput)
return nil, iamerr.NoSuchEntityUser(input.UserName)
}
return paginateTags(user.Tags, input.Marker, input.MaxItems), nil
return paginateTags(user.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
}
func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
@@ -959,7 +959,7 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs
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)
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
if err != nil {
return err
}
@@ -970,7 +970,7 @@ func (s *InternalStore) TagRole(_ context.Context, roleName string, tags []types
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)
role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded)
return nil
})
}
@@ -1015,7 +1015,7 @@ func (s *InternalStore) ListRoleTags(_ context.Context, input ListRoleTagsInput)
return nil, iamerr.NoSuchEntityRole(input.RoleName)
}
return paginateTags(role.Tags, input.Marker, input.MaxItems), nil
return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
}
func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error {
@@ -1357,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()
+4
View File
@@ -77,6 +77,10 @@ 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)
}
+45 -16
View File
@@ -645,7 +645,7 @@ func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate fu
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)
merged, err := mergeTags(user.Tags, tags, iamutil.TagKeysFolded)
if err != nil {
return err
}
@@ -657,7 +657,7 @@ func (s *VaultStore) TagUser(ctx context.Context, userName string, tags []types.
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)
user.Tags = removeTags(user.Tags, tagKeys, iamutil.TagKeysFolded)
return nil
})
return err
@@ -669,7 +669,7 @@ func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput)
return nil, err
}
return paginateTags(user.Tags, input.Marker, input.MaxItems), nil
return paginateTags(user.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
}
func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
@@ -1204,7 +1204,7 @@ 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)
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
if err != nil {
return err
}
@@ -1216,7 +1216,7 @@ func (s *VaultStore) TagRole(ctx context.Context, roleName string, tags []types.
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)
role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded)
return nil
})
return err
@@ -1228,7 +1228,7 @@ func (s *VaultStore) ListRoleTags(ctx context.Context, input ListRoleTagsInput)
return nil, err
}
return paginateTags(role.Tags, input.Marker, input.MaxItems), nil
return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil
}
func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error {
@@ -1527,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
}
@@ -1535,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
@@ -1547,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
@@ -1555,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
}
@@ -1657,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
}
@@ -1670,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
@@ -1681,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.
@@ -1723,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
}
+34
View File
@@ -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"`
}
+76
View File
@@ -1551,6 +1551,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)
@@ -1756,6 +1797,9 @@ func TestIAM(ts *TestState) {
TestIAMAddClientIDToOpenIDConnectProvider(ts)
TestIAMRemoveClientIDFromOpenIDConnectProvider(ts)
TestIAMUpdateOpenIDConnectProviderThumbprint(ts)
TestIAMTagOpenIDConnectProvider(ts)
TestIAMUntagOpenIDConnectProvider(ts)
TestIAMListOpenIDConnectProviderTags(ts)
TestIAMAssumeRoleWithWebIdentity(ts)
TestIAMGetCallerIdentity(ts)
TestIAMAccessControl(ts)
@@ -2487,6 +2531,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,
+36 -2
View File
@@ -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()),
@@ -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)
}
+454
View File
@@ -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,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)
}
+81 -12
View File
@@ -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 providers 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) {
+32
View File
@@ -2758,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) ----
/**
+37 -22
View File
@@ -168,10 +168,12 @@ function iamValidatePath(path) {
/**
* Validate a collected tag list against the same rules the server applies,
* so an obvious mistake is caught before a round trip. Returns an error
* string, or null when the list is acceptable.
* 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) {
function iamValidateTags(tags, caseSensitiveKeys = false) {
if (tags.length > IAM_LIMITS.tagsPerResource) {
return `A single resource can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
}
@@ -191,11 +193,13 @@ function iamValidateTags(tags) {
if (!IAM_LIMITS.tagValuePattern.test(tag.Value || '')) {
return `Tag value for "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`;
}
// Tag keys are compared case-insensitively, so "env" and "ENV" are the
// same key twice — which the service rejects outright.
const folded = tag.Key.toLowerCase();
if (seen.has(folded)) return `Tag key "${tag.Key}" is listed twice. Keys are case insensitive.`;
seen.add(folded);
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;
@@ -730,7 +734,7 @@ const iamTagEditor = {
<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 class="mt-1">Key/value labels for grouping and search. They are also readable from policy conditions as <code class="font-mono">aws:PrincipalTag/&lt;key&gt;</code> for the tagged identity and <code class="font-mono">aws:ResourceTag/&lt;key&gt;</code> for the identity being acted on. Keys are case insensitive; values may be empty.</p>
<p id="iam-tag-about" class="mt-1"></p>
</div>
</div>
</div>
@@ -762,11 +766,13 @@ const iamTagEditor = {
/**
* @param {Object} opts
* title modal heading
* subtitle the identity these tags belong to
* tags current tags, as [{Key, Value}]
* onSave async ({ set, remove }) => void, where set holds the tags to
* add or overwrite and remove holds the keys to drop
* 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();
@@ -774,6 +780,13 @@ const iamTagEditor = {
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/&lt;key&gt;</code> for the tagged identity and ' +
'<code class="font-mono">aws:ResourceTag/&lt;key&gt;</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 = '';
@@ -819,21 +832,23 @@ const iamTagEditor = {
},
/**
* Diff the edited rows against the tags the modal opened with. 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.
* 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 originalByKey = new Map(original.map(tag => [tag.Key.toLowerCase(), tag]));
const currentKeys = new Set(current.map(tag => tag.Key.toLowerCase()));
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(tag.Key.toLowerCase());
const before = originalByKey.get(key(tag));
return !before || before.Key !== tag.Key || (before.Value || '') !== (tag.Value || '');
});
const remove = original
.filter(tag => !currentKeys.has(tag.Key.toLowerCase()))
.filter(tag => !currentKeys.has(key(tag)))
.map(tag => tag.Key);
return { set, remove };
@@ -851,7 +866,7 @@ const iamTagEditor = {
if (orphanValue) { this._setStatus('Every tag needs a key.'); return; }
const current = iamCollectTags('iam-tag-rows');
const error = iamValidateTags(current);
const error = iamValidateTags(current, state.caseSensitiveKeys);
if (error) { this._setStatus(error); return; }
const { set, remove } = this._diff(current);