mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +00:00
feat: add IAM OIDC provider tagging actions
Adds `TagOpenIDConnectProvider`, `UntagOpenIDConnectProvider` and `ListOpenIDConnectProviderTags` to the standalone IAM service, backed by both the internal and Vault storers. They follow the user and role tagging actions in most respects — the tag action merges into the provider's existing tags and rejects a repeated key, untag removal is idempotent, and the tag listing is sorted by key and paginated, with the per-request member count and the per-provider tag total enforced as separate quotas so replacing a tag on a provider already at the 50-tag cap still succeeds — but differ in the one respect IAM itself draws: OIDC provider tag keys are compared exactly, not case-insensitively. On a provider `env` and `ENV` are two independent tags, both may be supplied in a single request, only a byte-identical repeat is a duplicate (reported without the "Tag keys are case insensitive" note the user and role actions carry), and untagging `env` leaves `ENV` in place. That distinction is now carried by `iamutil.TagKeyCase`, which `ParseTags` uses for duplicate detection and which `mergeTags`, `removeTags` and the tag listing's marker lookup use for key matching. `CreateOpenIDConnectProvider` moves onto the exact comparison too, so a provider created with case-differing tag keys keeps both. All three actions are authorized against the target provider's ARN, so `aws:ResourceTag/<key>` reads the provider's own tags, and the tag and untag actions populate `aws:RequestTag/<key>` and `aws:TagKeys` respectively, so a tag-scoped policy Condition governs which tags a caller may set or remove. All three report a missing provider with the wording `DeleteOpenIDConnectProvider` uses rather than the one `GetOpenIDConnectProvider` uses, which is why the Vault provider read now takes the not-found error its calling action reports. The WebGUI gains a Tags section in the OIDC provider manage view, replacing the read-only tag row, and the shared tag editor gains a case-sensitive mode that changes its duplicate-key check, its diffing of an edited set into an untag and tag pair, and the wording of its guidance.
This commit is contained in:
+88
-5
@@ -61,7 +61,7 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func (c IAMApiController) TagUser(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -662,7 +662,7 @@ func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -826,7 +826,7 @@ func (c IAMApiController) TagRole(ctx fiber.Ctx) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response,
|
||||
thumbprints = iamutil.NormalizeThumbprintList(thumbprints)
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx)
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1214,6 +1214,89 @@ func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) (
|
||||
return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil
|
||||
}
|
||||
|
||||
// TagOpenIDConnectProvider adds or replaces tags on an existing OIDC
|
||||
// provider. Unlike the user and role tag actions, provider tag keys are
|
||||
// compared exactly, so "env" and "ENV" are two distinct tags.
|
||||
func (c IAMApiController) TagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "TagOpenIDConnectProvider")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
debuglogger.Logf("missing required TagOpenIDConnectProvider parameter: Tags")
|
||||
return nil, iamerr.MissingValue("tags")
|
||||
}
|
||||
|
||||
if err := c.store.TagOIDCProvider(ctx.Context(), arn, tags); err != nil {
|
||||
debuglogger.Logf("failed to tag IAM OIDC provider %q: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.TagOpenIDConnectProviderResponse{}}, nil
|
||||
}
|
||||
|
||||
// UntagOpenIDConnectProvider removes the named tags from an existing OIDC
|
||||
// provider. Removal is idempotent: a key naming no current tag is not an
|
||||
// error.
|
||||
func (c IAMApiController) UntagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "UntagOpenIDConnectProvider")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagKeys, err := iamutil.ParseTagKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tagKeys) == 0 {
|
||||
debuglogger.Logf("missing required UntagOpenIDConnectProvider parameter: TagKeys")
|
||||
return nil, iamerr.MissingValue("tagKeys")
|
||||
}
|
||||
|
||||
if err := c.store.UntagOIDCProvider(ctx.Context(), arn, tagKeys); err != nil {
|
||||
debuglogger.Logf("failed to untag IAM OIDC provider %q: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.UntagOpenIDConnectProviderResponse{}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) ListOpenIDConnectProviderTags(ctx fiber.Ctx) (*Response, error) {
|
||||
arn, err := iamutil.GetOIDCProviderArn(ctx, "ListOpenIDConnectProviderTags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems, err := iamutil.ParseMaxItems(ctx, "ListOpenIDConnectProviderTags")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
marker, _ := iamutil.RequestParam(ctx, "Marker")
|
||||
out, err := c.store.ListOIDCProviderTags(ctx.Context(), storage.ListOIDCProviderTagsInput{
|
||||
Arn: arn,
|
||||
Marker: marker,
|
||||
MaxItems: maxItems,
|
||||
})
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to list IAM OIDC provider %q tags: %v", arn, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Response{Data: &types.ListOpenIDConnectProviderTagsResponse{
|
||||
Result: types.ListOpenIDConnectProviderTagsResult{
|
||||
Tags: types.Tags{Members: out.Tags},
|
||||
IsTruncated: out.IsTruncated,
|
||||
Marker: out.Marker,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, error) {
|
||||
rawRoleArn, ok := iamutil.RequestParam(ctx, "RoleArn")
|
||||
if !ok || rawRoleArn == "" {
|
||||
|
||||
+454
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -103,6 +103,10 @@ func (r *IAMApiRouter) Init() {
|
||||
"AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider,
|
||||
"RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider,
|
||||
"UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint,
|
||||
// OIDC Provider Tagging
|
||||
"TagOpenIDConnectProvider": r.Ctrl.TagOpenIDConnectProvider,
|
||||
"UntagOpenIDConnectProvider": r.Ctrl.UntagOpenIDConnectProvider,
|
||||
"ListOpenIDConnectProviderTags": r.Ctrl.ListOpenIDConnectProviderTags,
|
||||
// STS actions (routed through this same endpoint; see stsActions)
|
||||
"AssumeRoleWithWebIdentity": r.Ctrl.AssumeRoleWithWebIdentity,
|
||||
"GetCallerIdentity": r.Ctrl.GetCallerIdentity,
|
||||
|
||||
+24
-17
@@ -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:]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -77,6 +77,10 @@ type Storer interface {
|
||||
RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error
|
||||
UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error
|
||||
|
||||
TagOIDCProvider(ctx context.Context, arn string, tags []types.Tag) error
|
||||
UntagOIDCProvider(ctx context.Context, arn string, tagKeys []string) error
|
||||
ListOIDCProviderTags(ctx context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error)
|
||||
|
||||
CreateSession(ctx context.Context, session types.Session) (*types.Session, error)
|
||||
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
|
||||
}
|
||||
|
||||
+45
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user