diff --git a/iamapi/controller.go b/iamapi/controller.go index dd337cd0..672ba29f 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -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 == "" { diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 8a2d8078..6a935f10 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -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 { diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 0c5cd87b..bf6831ca 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -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) } diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go index 6201fd35..6747d604 100644 --- a/iamapi/internal/iammiddleware/policy.go +++ b/iamapi/internal/iammiddleware/policy.go @@ -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 } diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 0f3a8d2c..c746811b 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -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. diff --git a/iamapi/router.go b/iamapi/router.go index 48620acb..d68e1e8d 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -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, diff --git a/iamapi/storage/common.go b/iamapi/storage/common.go index 4858067e..a34386b4 100644 --- a/iamapi/storage/common.go +++ b/iamapi/storage/common.go @@ -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:] diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 7f8cdb6e..3f68e1f7 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -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() diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index fee63758..801422e7 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -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) } diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 0ea29eb1..43c377fe 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -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 } diff --git a/iamapi/types/oidc.go b/iamapi/types/oidc.go index d8ee2e84..201a8d4f 100644 --- a/iamapi/types/oidc.go +++ b/iamapi/types/oidc.go @@ -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"` +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 23605f39..956a71cc 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -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, diff --git a/tests/integration/iam_create_oidc_provider.go b/tests/integration/iam_create_oidc_provider.go index 66832078..cedcd89e 100644 --- a/tests/integration/iam_create_oidc_provider.go +++ b/tests/integration/iam_create_oidc_provider.go @@ -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()), diff --git a/tests/integration/iam_list_oidc_provider_tags.go b/tests/integration/iam_list_oidc_provider_tags.go new file mode 100644 index 00000000..9577b1c8 --- /dev/null +++ b/tests/integration/iam_list_oidc_provider_tags.go @@ -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) +} diff --git a/tests/integration/iam_tag_oidc_provider.go b/tests/integration/iam_tag_oidc_provider.go new file mode 100644 index 00000000..6022de06 --- /dev/null +++ b/tests/integration/iam_tag_oidc_provider.go @@ -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) +} diff --git a/tests/integration/iam_untag_oidc_provider.go b/tests/integration/iam_untag_oidc_provider.go new file mode 100644 index 00000000..511c7613 --- /dev/null +++ b/tests/integration/iam_untag_oidc_provider.go @@ -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) +} diff --git a/webui/web/iam-oidc.html b/webui/web/iam-oidc.html index b4ce1db1..157330b2 100644 --- a/webui/web/iam-oidc.html +++ b/webui/web/iam-oidc.html @@ -219,7 +219,7 @@ under the License.
-

Tags are set at creation only.

+

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

@@ -239,7 +239,7 @@ under the License.

Provider

-

URL and tags are fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.

+

The URL is fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.

-
-
Tags
-
-
-
+ +
+
+
+

Tags

+

Key/value labels, also readable from policy conditions.

+
+ +
+
+
+

Audiences / Client IDs

@@ -360,6 +368,7 @@ under the License.