From 89b64910f3a09c66ff7ff0f65b8a94f2a830b636 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Fri, 10 Jul 2026 03:17:23 +0400 Subject: [PATCH] feat: add IAM user inline policy CRUD Add support for AWS-compatible inline identity-based policies on IAM users, implementing the `PutUserPolicy`, `GetUserPolicy`, `DeleteUserPolicy`, and `ListUserPolicies` actions on both the internal and Vault storage backends. - iamapi/policy is a new package that parses and validates policy documents against IAM's parameter-level constraints (max length, allowed charset) and policy grammar (Version, Effect, mutually exclusive Action/NotAction and Resource/NotResource, vendor-prefixed actions, ARN-shaped resources, no Principal/NotPrincipal, unique Sids). - `PutUserPolicy` creates or replaces a named inline policy on a user, enforcing a 2048-byte aggregate quota across all of a user's inline policies (MaxInlinePolicyBytesPerUser), matching the AWS IAM quota. - `GetUserPolicy` returns a policy's document RFC 3986 percent-encoded, matching how real IAM encodes the PolicyDocument response element. - `DeleteUserPolicy` removes a named inline policy from a user. - `ListUserPolicies` returns a paginated, sorted list of a user's inline policy names, honoring Marker/MaxItems like the other IAM list APIs. - `DeleteUser` is now rejected with a DeleteConflict error if the user still has inline policies attached, mirroring the existing access-key delete-conflict behavior. --- iamapi/controller.go | 216 +++++++---- iamapi/controller_test.go | 369 +++++++++++++++++++ iamapi/iamerr/errors.go | 38 +- iamapi/internal/iamutil/policy.go | 29 ++ iamapi/internal/iamutil/user.go | 57 ++- iamapi/policy/document.go | 103 ++++++ iamapi/policy/document_test.go | 114 ++++++ iamapi/policy/validate.go | 227 ++++++++++++ iamapi/policy/validate_test.go | 123 +++++++ iamapi/router.go | 5 + iamapi/storage/internal.go | 158 ++++++++ iamapi/storage/storer.go | 27 ++ iamapi/storage/vault.go | 119 +++++++ iamapi/types/policy.go | 96 +++++ iamapi/types/user.go | 1 + tests/integration/group-tests.go | 74 ++++ tests/integration/iam_delete_user_policy.go | 203 +++++++++++ tests/integration/iam_get_user_policy.go | 165 +++++++++ tests/integration/iam_list_user_policies.go | 223 ++++++++++++ tests/integration/iam_put_user_policy.go | 376 ++++++++++++++++++++ 20 files changed, 2645 insertions(+), 78 deletions(-) create mode 100644 iamapi/internal/iamutil/policy.go create mode 100644 iamapi/policy/document.go create mode 100644 iamapi/policy/document_test.go create mode 100644 iamapi/policy/validate.go create mode 100644 iamapi/policy/validate_test.go create mode 100644 iamapi/types/policy.go create mode 100644 tests/integration/iam_delete_user_policy.go create mode 100644 tests/integration/iam_get_user_policy.go create mode 100644 tests/integration/iam_list_user_policies.go create mode 100644 tests/integration/iam_put_user_policy.go diff --git a/iamapi/controller.go b/iamapi/controller.go index 9718836e..ef4b5086 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -17,13 +17,13 @@ package iamapi import ( "errors" "fmt" - "strconv" "time" "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/policy" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/iamapi/types" ) @@ -37,12 +37,8 @@ func NewController(store storage.Storer) IAMApiController { } func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok { - debuglogger.Logf("missing required CreateUser parameter: UserName") - return nil, iamerr.GetAPIError(iamerr.ErrMissingUserNameValue) - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserNameLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "CreateUser", iamutil.MaxUserNameLen, iamerr.MissingValue("userName")) + if err != nil { return nil, err } @@ -95,12 +91,8 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteUser(ctx fiber.Ctx) (*Response, error) { - username, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || username == "" { - debuglogger.Logf("missing required DeleteUser parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + username, err := iamutil.GetUserName(ctx, "DeleteUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -126,7 +118,7 @@ func (c IAMApiController) GetUser(ctx fiber.Ctx) (*Response, error) { }}, }}, nil } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + if err := iamutil.ValidateName("userName", username, iamutil.MaxUserLookupLen); err != nil { return nil, err } @@ -150,14 +142,9 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { return nil, err } - maxItems := int32(iamutil.DefaultMaxItems) - if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" { - parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) - if err != nil || parsed < 1 || parsed > iamutil.MaxListItems { - debuglogger.Logf("invalid ListUsers MaxItems value %q: parse_error=%v", rawMaxItems, err) - return nil, iamerr.InvalidMaxItems(rawMaxItems) - } - maxItems = int32(parsed) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUsers") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -181,12 +168,8 @@ func (c IAMApiController) ListUsers(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { - username, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || username == "" { - debuglogger.Logf("missing required UpdateUser parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", username, iamutil.MaxUserLookupLen); err != nil { + username, err := iamutil.GetUserName(ctx, "UpdateUser", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -198,7 +181,7 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } newUserName, _ := iamutil.RequestParam(ctx, "NewUserName") if newUserName != "" { - if err := iamutil.ValidateUserName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { + if err := iamutil.ValidateName("newUserName", newUserName, iamutil.MaxUserNameLen); err != nil { return nil, err } } @@ -235,12 +218,8 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required CreateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -277,18 +256,14 @@ func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { }, nil } - err := fmt.Errorf("generate IAM access key id: exhausted collision retries") + err = fmt.Errorf("generate IAM access key id: exhausted collision retries") debuglogger.Logf("failed to create IAM access key for user %q: %v", userName, err) return nil, err } func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required UpdateAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "UpdateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -323,12 +298,8 @@ func (c IAMApiController) UpdateAccessKey(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteAccessKey(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required DeleteAccessKey parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "DeleteAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } @@ -392,23 +363,14 @@ func (c IAMApiController) GetAccessKeyLastUsed(ctx fiber.Ctx) (*Response, error) } func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { - userName, ok := iamutil.RequestParam(ctx, "UserName") - if !ok || userName == "" { - debuglogger.Logf("missing required ListAccessKeys parameter: UserName") - return nil, iamerr.MissingParameter("UserName") - } - if err := iamutil.ValidateUserName("userName", userName, iamutil.MaxUserLookupLen); err != nil { + userName, err := iamutil.GetUserName(ctx, "ListAccessKeys", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) + if err != nil { return nil, err } - maxItems := int32(iamutil.DefaultMaxItems) - if rawMaxItems, ok := iamutil.RequestParam(ctx, "MaxItems"); ok && rawMaxItems != "" { - parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) - if err != nil || parsed < 1 || parsed > iamutil.MaxListItems { - debuglogger.Logf("invalid ListAccessKeys MaxItems value %q: parse_error=%v", rawMaxItems, err) - return nil, iamerr.InvalidMaxItems(rawMaxItems) - } - maxItems = int32(parsed) + maxItems, err := iamutil.ParseMaxItems(ctx, "ListAccessKeys") + if err != nil { + return nil, err } marker, _ := iamutil.RequestParam(ctx, "Marker") @@ -430,3 +392,133 @@ func (c IAMApiController) ListAccessKeys(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) PutUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required PutUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "PutUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + // Confirm the user exists before inspecting policy document content + if _, err := c.store.GetUser(ctx.Context(), userName); err != nil { + debuglogger.Logf("failed to get IAM user %q for PutUserPolicy: %v", userName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutUserPolicy(ctx.Context(), storage.PutUserPolicyInput{ + UserName: userName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.PutUserPolicyResponse{}}, nil +} + +func (c IAMApiController) GetUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "GetUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetUserPolicy(ctx.Context(), userName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.GetUserPolicyResponse{ + Result: types.GetUserPolicyResult{ + UserName: userName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteUserPolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteUserPolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + userName, err := iamutil.GetUserName(ctx, "DeleteUserPolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteUserPolicy(ctx.Context(), userName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM user policy %q for user %q: %v", policyName, userName, err) + return nil, err + } + + return &Response{Data: &types.DeleteUserPolicyResponse{}}, nil +} + +func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "ListUserPolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserPolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListUserPolicies(ctx.Context(), storage.ListUserPoliciesInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM user policies for user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListUserPoliciesResponse{ + Result: types.ListUserPoliciesResult{ + PolicyNames: types.PolicyNameList{Members: out.PolicyNames}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 88b59f45..58cf4db8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/gofiber/fiber/v3" "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" @@ -478,6 +479,355 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.") } +func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createUser := doIAMAction(t, server, url.Values{ + "Action": {"CreateUser"}, + "UserName": {"alice"}, + }) + if createUser.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", createUser.StatusCode, readBody(t, createUser)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutUserPolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutUserPolicyResponse" { + t.Fatalf("PutUserPolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutUserPolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetUserPolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.UserName != "alice" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetUserPolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument) + } + decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if decoded != policyDoc { + t.Fatalf("GetUserPolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListUserPolicies"}, + "UserName": {"alice"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListUserPolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListUserPoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListUserPolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListUserPolicies IsTruncated = true, want false") + } + + // Re-Put-ing the same PolicyName replaces it rather than erroring or + // stacking toward the aggregate size quota. + overwritePut := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutUserPolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetUserPolicyResponse + unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut) + overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument) + if err != nil { + t.Fatalf("QueryUnescape: %v", err) + } + if !strings.Contains(overwriteDecoded, "Deny") { + t.Fatalf("GetUserPolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteUserPolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteUserPolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteUserPolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") + + // A second delete of the same (now-gone) policy is a hard error, not an + // idempotent success. + doubleDelete := doIAMAction(t, server, url.Values{ + "Action": {"DeleteUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The user policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupUser bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put missing user name", + params: url.Values{"Action": {"PutUserPolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "put long policy name", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "put non-ascii policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.", + }, + { + name: "put user does not exist", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put nonexistent user wins over malformed document", + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": { + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, + }}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Policy document should not specify a principal.", + }, + { + name: "put policy document exceeds aggregate size quota", + setupUser: true, + params: url.Values{"Action": {"PutUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}, "PolicyDocument": {oversizedDoc}}, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Maximum policy size of 2048 bytes exceeded for user alice", + }, + { + name: "get user does not exist", + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupUser: true, + params: url.Values{"Action": {"GetUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete user does not exist", + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupUser: true, + params: url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list user does not exist", + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupUser: true, + params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupUser { + resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerPutUserPolicyOversizedDocument(t *testing.T) { + // A >131072 byte PolicyDocument does not fit in a GET query string + // against this test server's header/URL read-buffer limit, matching + // real IAM's own guidance to use POST rather than GET for large + // policy documents - so this one case is exercised over POST directly + // rather than through the doIAMAction GET helper used elsewhere. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {strings.Repeat("x", 131073)}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072") +} + +func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutUserPolicy"}, + "UserName": {"alice"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutUserPolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deletePolicyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deletePolicyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + // When both an access key and a policy are attached, the policy + // conflict is reported first. + createKey := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"alice"}}) + if createKey.StatusCode != http.StatusOK { + t.Fatalf("CreateAccessKey status = %d, body=%s", createKey.StatusCode, readBody(t, createKey)) + } + deleteBoth := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteBoth, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteUserPolicy"}, "UserName": {"alice"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteUserPolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteKeyOnly := doIAMAction(t, server, url.Values{"Action": {"DeleteUser"}, "UserName": {"alice"}}) + requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() @@ -506,6 +856,25 @@ func doIAMAction(t *testing.T, server *IAMApiServer, params url.Values) *http.Re return resp } +// doIAMActionPost signs and sends params as a POST form body rather than a +// GET query string, for requests too large to fit a GET request's +// header/URL buffer (e.g. an oversized PolicyDocument). +func doIAMActionPost(t *testing.T, server *IAMApiServer, params url.Values) *http.Response { + t.Helper() + if !params.Has("Version") { + params.Set("Version", iamAPIVersion) + } + + req := signedIAMRequest(t, http.MethodPost, "http://example.com/", []byte(params.Encode()), testRoot.Secret) + req.Header.Set("Content-Type", fiber.MIMEApplicationForm) + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + return resp +} + func unmarshalXML(t *testing.T, body string, out any) { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index f3c2d001..5c55c6dd 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -54,12 +54,12 @@ const ( ErrInvalidClientTokenID ErrInvalidContentLength ErrThrottling - ErrMissingUserNameValue ErrTooManyTags ErrInvalidPathPrefix ErrDuplicateTagKeys ErrInvalidAccessKeyIDChars ErrDeleteConflict + ErrDeleteConflictPolicies ) type APIError interface { @@ -207,12 +207,6 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "'Host' or ':authority' must be a 'SignedHeader' in the AWS Authorization.", HTTPStatusCode: http.StatusForbidden, }, - ErrMissingUserNameValue: { - Type: TypeSender, - Code: "ValidationError", - Message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", - HTTPStatusCode: http.StatusBadRequest, - }, ErrInvalidPathPrefix: { Type: TypeSender, Code: "ValidationError", @@ -243,6 +237,12 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "Cannot delete entity, must delete access keys first.", HTTPStatusCode: http.StatusConflict, }, + ErrDeleteConflictPolicies: { + Type: TypeSender, + Code: "DeleteConflict", + Message: "Cannot delete entity, must delete policies first.", + HTTPStatusCode: http.StatusConflict, + }, } func GetAPIError(code ErrorCode) Error { @@ -405,6 +405,30 @@ func InvalidTagValue(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*", index)) } +func MissingValue(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must not be null", field)) +} + +func ValueTooLong(field string, maxLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength)) +} + +func InvalidCharset(field string) Error { + return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) +} + +func MalformedPolicyDocument(message string) Error { + return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) +} + +func NoSuchEntityUserPolicy(userName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) +} + +func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/policy.go b/iamapi/internal/iamutil/policy.go new file mode 100644 index 00000000..9636a12e --- /dev/null +++ b/iamapi/internal/iamutil/policy.go @@ -0,0 +1,29 @@ +// 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 iamutil + +import ( + "net/url" + "strings" +) + +// EncodePolicyDocument RFC 3986 percent-encodes a policy document string +// the way real IAM encodes the PolicyDocument element of GetUserPolicy (and +// will for GetRolePolicy) responses: every character outside the unreserved +// set is percent-encoded, with the space character encoded as %20 rather +// than the "+" that url.QueryEscape alone would produce. +func EncodePolicyDocument(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 19bcb921..68bcb667 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -19,6 +19,7 @@ import ( "fmt" "math/big" "regexp" + "strconv" "strings" "github.com/gofiber/fiber/v3" @@ -43,9 +44,9 @@ const ( ) var ( - userNamePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`) - tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) - tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) + namePattern = regexp.MustCompile(`^[A-Za-z0-9+=,.@_-]+$`) + tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) + tagValPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) ) // RequestParam looks up key first in URL query args, then in the POST body. @@ -63,6 +64,42 @@ func RequestParam(ctx fiber.Ctx, key string) (string, bool) { return "", false } +// GetUserName resolves the UserName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +// operation is included in the debug log on failure (e.g. "DeleteUser"). +// missingErr lets callers match the exact AWS error their operation is +// verified against (e.g. iamerr.MissingValue vs iamerr.MissingParameter). +func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + userName, ok := RequestParam(ctx, "UserName") + if !ok || userName == "" { + debuglogger.Logf("missing required %s parameter: UserName", operation) + return "", missingErr + } + if err := ValidateName("userName", userName, maxLen); err != nil { + return "", err + } + + return userName, nil +} + +// ParseMaxItems reads the MaxItems request parameter, defaulting to +// DefaultMaxItems when absent. operation is included in the debug log on +// parse failure (e.g. "ListUsers", "ListAccessKeys"). +func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) { + rawMaxItems, ok := RequestParam(ctx, "MaxItems") + if !ok || rawMaxItems == "" { + return int32(DefaultMaxItems), nil + } + + parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) + if err != nil || parsed < 1 || parsed > MaxListItems { + debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err) + return 0, iamerr.InvalidMaxItems(rawMaxItems) + } + + return int32(parsed), nil +} + // ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list. func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { var tags []types.Tag @@ -106,14 +143,16 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { return tags, nil } -// ValidateUserName checks that userName is non-empty, matches the allowed character set, and fits within maxLength. -func ValidateUserName(field, userName string, maxLength int) error { - if len(userName) > maxLength { - debuglogger.Logf("IAM user name exceeds maximum length: field=%s length=%d max=%d", field, len(userName), maxLength) +// ValidateName checks that name (an IAM identity or policy name, e.g. +// userName or policyName) is non-empty, matches the allowed character set, +// and fits within maxLength. +func ValidateName(field, name string, maxLength int) error { + if len(name) > maxLength { + debuglogger.Logf("IAM name exceeds maximum length: field=%s length=%d max=%d", field, len(name), maxLength) return iamerr.UserNameTooLong(field, maxLength) } - if userName == "" || !userNamePattern.MatchString(userName) { - debuglogger.Logf("invalid IAM user name: field=%s value=%q", field, userName) + if name == "" || !namePattern.MatchString(name) { + debuglogger.Logf("invalid IAM name: field=%s value=%q", field, name) return iamerr.InvalidUserName(field) } diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go new file mode 100644 index 00000000..39a7bc04 --- /dev/null +++ b/iamapi/policy/document.go @@ -0,0 +1,103 @@ +// 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 policy + +import ( + "bytes" + "encoding/json" +) + +// Recognized values for a policy document's Version element. +const ( + Version2008 = "2008-10-17" + Version2012 = "2012-10-17" +) + +// Document is a parsed AWS IAM policy document. +type Document struct { + Version string + Statement []Statement +} + +// Statement is a single element of a policy document's Statement list. +type Statement struct { + Sid string + Effect string + Action StringOrSlice + NotAction StringOrSlice + Resource StringOrSlice + NotResource StringOrSlice + Principal json.RawMessage + NotPrincipal json.RawMessage +} + +// UnmarshalJSON accepts Statement as either a single JSON object or an +// array of objects, matching the AWS IAM policy grammar. A missing or +// JSON-null Statement leaves Document.Statement nil rather than erroring +// here — Validate reports that as a grammar error so all "empty document" +// shapes produce the same message. +func (d *Document) UnmarshalJSON(data []byte) error { + var raw struct { + Version string + Statement json.RawMessage + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + d.Version = raw.Version + + if len(raw.Statement) == 0 || string(bytes.TrimSpace(raw.Statement)) == "null" { + return nil + } + + var stmts []Statement + if err := json.Unmarshal(raw.Statement, &stmts); err == nil { + d.Statement = stmts + return nil + } + + var single Statement + if err := json.Unmarshal(raw.Statement, &single); err != nil { + return err + } + d.Statement = []Statement{single} + return nil +} + +// StringOrSlice decodes a JSON value that may be either a single string or +// an array of strings, matching the AWS IAM policy grammar for Action, +// NotAction, Resource, and NotResource. A JSON-null value decodes to a nil +// StringOrSlice, identical to the key being absent. +type StringOrSlice []string + +func (s *StringOrSlice) UnmarshalJSON(data []byte) error { + if string(bytes.TrimSpace(data)) == "null" { + *s = nil + return nil + } + + var single string + if err := json.Unmarshal(data, &single); err == nil { + *s = StringOrSlice{single} + return nil + } + + var multi []string + if err := json.Unmarshal(data, &multi); err != nil { + return err + } + *s = StringOrSlice(multi) + return nil +} diff --git a/iamapi/policy/document_test.go b/iamapi/policy/document_test.go new file mode 100644 index 00000000..bf9437b2 --- /dev/null +++ b/iamapi/policy/document_test.go @@ -0,0 +1,114 @@ +// 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 policy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestStringOrSliceUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want StringOrSlice + }{ + {"single string", `"s3:GetObject"`, StringOrSlice{"s3:GetObject"}}, + {"array of strings", `["s3:GetObject","s3:PutObject"]`, StringOrSlice{"s3:GetObject", "s3:PutObject"}}, + {"empty array", `[]`, StringOrSlice{}}, + {"empty string", `""`, StringOrSlice{""}}, + {"null", `null`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got StringOrSlice + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Unmarshal() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDocumentUnmarshalJSON(t *testing.T) { + t.Run("statement as array", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement as single object", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(doc.Statement) != 1 { + t.Fatalf("got %d statements, want 1", len(doc.Statement)) + } + }) + + t.Run("statement absent leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17"}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("statement null leaves nil, not an unmarshal error", func(t *testing.T) { + var doc Document + err := json.Unmarshal([]byte(`{"Version":"2012-10-17","Statement":null}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Statement != nil { + t.Fatalf("Statement = %#v, want nil", doc.Statement) + } + }) + + t.Run("version absent leaves empty string, not defaulted", func(t *testing.T) { + // Unlike auth's S3 bucket-policy engine (which defaults a missing + // Version to 2008-10-17), real IAM leaves an omitted Version on an + // identity policy exactly as submitted - no default is injected. + var doc Document + err := json.Unmarshal([]byte(`{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), &doc) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if doc.Version != "" { + t.Fatalf("Version = %q, want empty", doc.Version) + } + }) + + t.Run("top-level non-object is an unmarshal error", func(t *testing.T) { + var doc Document + if err := json.Unmarshal([]byte(`"hello"`), &doc); err == nil { + t.Fatal("Unmarshal() error = nil, want non-nil") + } + }) +} diff --git a/iamapi/policy/validate.go b/iamapi/policy/validate.go new file mode 100644 index 00000000..8e14b06b --- /dev/null +++ b/iamapi/policy/validate.go @@ -0,0 +1,227 @@ +// 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 policy + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// MaxDocumentLength is IAM's parameter-level maximum length for a +// PolicyDocument value. +const MaxDocumentLength = 131072 + +// vendorPattern is the inferred grammar for the service prefix of a policy +// action/resource (the text before the first ':', e.g. "s3", "iam", +// "elasticloadbalancing"). AWS does not publish this pattern; alphanumeric +// + hyphen matches every real service prefix and was verified to reject an +// empty or space-containing prefix the same way live IAM does. +var vendorPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +// validPartition is the only ARN partition name supported byt the gateway: real +// IAM also accepts "aws-cn", "aws-us-gov", and the "aws-iso*" partitions, +// but this deployment only ever runs in the standard "aws" partition, so a +// resource ARN whose partition field is anything else is rejected +const validPartition = "aws" + +var ( + errSyntax = iamerr.MalformedPolicyDocument("Syntax errors in policy.") + errMissingActions = iamerr.MalformedPolicyDocument("Policy statement must contain actions.") + errMissingResources = iamerr.MalformedPolicyDocument("Policy statement must contain resources.") + errPrincipalNotAllowed = iamerr.MalformedPolicyDocument("Policy document should not specify a principal.") + errDuplicateSid = iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.") + errMissingVendorPrefix = iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.") + errLegacyParsing = iamerr.MalformedPolicyDocument("The policy failed legacy parsing") +) + +// Validate checks raw against IAM's parameter-level constraints for a +// PolicyDocument value: a maximum length of 131072 and the allowed +// character set (tab/LF/CR plus printable Latin-1, U+0020-U+00FF, with at +// least one such character present — so an empty value is rejected here +// too, as a charset violation). +func Validate(field, raw string) error { + if len(raw) > MaxDocumentLength { + return iamerr.ValueTooLong(field, MaxDocumentLength) + } + if !isValidDocumentCharset(raw) { + return iamerr.InvalidCharset(field) + } + return nil +} + +func isValidDocumentCharset(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || r > 0xFF { + return false + } + } + return true +} + +// Parse parses raw as an IAM policy document and checks it against IAM +// policy grammar +func Parse(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errSyntax + } + return doc.Validate() +} + +// Validate checks d against IAM policy document grammar: a valid Version if +// present, a non-empty Statement (single object or array), document-wide +// unique Sids, and per statement, the rules enforced by Statement.Validate. +func (d Document) Validate() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errSyntax + } + if len(d.Statement) == 0 { + return errSyntax + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.Validate(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// Validate checks s against IAM policy statement grammar: a valid Effect, +// no Principal/NotPrincipal, an Action or NotAction (not both) with +// vendor-prefixed values, and a Resource or NotResource (not both) with +// ARN-shaped values. Condition is not modeled or validated. +func (s Statement) Validate() error { + switch s.Effect { + case "Allow", "Deny": + default: + return errSyntax + } + + if len(s.Principal) > 0 || len(s.NotPrincipal) > 0 { + return errPrincipalNotAllowed + } + + if len(s.Action) > 0 && len(s.NotAction) > 0 { + return errSyntax + } + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errMissingActions + } + for _, action := range s.Action { + if err := validateActionVendor(action); err != nil { + return err + } + } + for _, action := range s.NotAction { + if err := validateActionVendor(action); err != nil { + return err + } + } + + if len(s.Resource) > 0 && len(s.NotResource) > 0 { + return errSyntax + } + if len(s.Resource) == 0 && len(s.NotResource) == 0 { + return errMissingResources + } + for _, resource := range s.Resource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + for _, resource := range s.NotResource { + if err := validateResourceARN(resource); err != nil { + return err + } + } + + return nil +} + +// validateActionVendor checks that action is either the bare wildcard "*" +// or has a syntactically valid "vendor:name" shape. The action name after +// the colon is not checked against any known service/action list — real +// IAM accepts unrecognized service/action names at this stage too. +func validateActionVendor(action string) error { + if action == "*" { + return nil + } + before, _, ok := strings.Cut(action, ":") + if !ok { + return errMissingVendorPrefix + } + vendor := before + if !vendorPattern.MatchString(vendor) { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Vendor %s is not valid", vendor)) + } + return nil +} + +// validateResourceARN checks a single Resource/NotResource entry against +// IAM's ARN grammar: either the bare wildcard "*", or +// "arn:partition:service:region:account:resource". The service, region, +// account, and resource fields are not further validated — only the +// partition is checked, matching what real IAM enforces at this stage +func validateResourceARN(resource string) error { + if resource == "*" { + return nil + } + if !strings.Contains(resource, ":") { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Resource %s must be in ARN format or \"*\".", resource)) + } + + if strings.HasPrefix(resource, "arn:") { + fields := strings.SplitN(resource[len("arn:"):], ":", 5) + if len(fields) < 5 { + return errLegacyParsing + } + partition := fields[0] + if partition != validPartition { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, resource)) + } + return nil + } + + tokens := strings.SplitN(resource, ":", 6) + field := func(i int) string { + if i < len(tokens) { + return tokens[i] + } + return "*" + } + partition := field(1) + reconstructed := fmt.Sprintf("arn:%s:%s:%s:%s:%s", partition, field(2), field(3), field(4), field(5)) + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Partition %q is not valid for resource %q.", partition, reconstructed)) +} diff --git a/iamapi/policy/validate_test.go b/iamapi/policy/validate_test.go new file mode 100644 index 00000000..8019a6ee --- /dev/null +++ b/iamapi/policy/validate_test.go @@ -0,0 +1,123 @@ +// 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 policy + +import ( + "errors" + "strings" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account. +func TestValidate(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means Validate must succeed + }{ + {"valid single statement", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid statement as single object, not array", `{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid bare wildcard action and resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`, nil}, + {"valid NotAction alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"s3:GetObject","Resource":"*"}]}`, nil}, + {"valid NotResource alone", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"*"}]}`, nil}, + {"valid unrecognized vendor/action accepted", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"totallyfakeservice:DoSomething","Resource":"*"}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"B","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, nil}, + {"valid action array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::b","arn:aws:s3:::b/*"]}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errSyntax}, + {"empty object", `{}`, errSyntax}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"missing statement", `{"Version":"2012-10-17"}`, errSyntax}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, errSyntax}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errSyntax}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, errSyntax}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, errSyntax}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, errSyntax}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, errSyntax}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, errSyntax}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, errMissingActions}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, errMissingResources}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, errMissingResources}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, errMissingVendorPrefix}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, errMissingVendorPrefix}, + + {"principal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + {"notprincipal present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":"*","Action":"s3:GetObject","Resource":"*"}]}`, errPrincipalNotAllowed}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, errDuplicateSid}, + + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, errLegacyParsing}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + {"notresource with invalid shape", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"principal only, no action or resource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:user/bob"}}]}`, errPrincipalNotAllowed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Parse(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Validate() = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateSize(t *testing.T) { + tests := []struct { + name string + raw string + wantErr error + }{ + {"valid small document", `{}`, nil}, + {"tab, newline, and carriage return allowed", "a\tb\nc\rd", nil}, + {"empty", "", iamerr.InvalidCharset("policyDocument")}, + {"exactly at max length", strings.Repeat("x", MaxDocumentLength), nil}, + {"one over max length", strings.Repeat("x", MaxDocumentLength+1), iamerr.ValueTooLong("policyDocument", MaxDocumentLength)}, + {"non-latin1 rune rejected", "emoji\U0001F600test", iamerr.InvalidCharset("policyDocument")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate("policyDocument", tt.raw) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateSize() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateSize() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ce6eb55c..4b4baa70 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -57,6 +57,11 @@ func (r *IAMApiRouter) Init() { "DeleteAccessKey": ctrl.DeleteAccessKey, "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, "ListAccessKeys": ctrl.ListAccessKeys, + // User Inline Policy CRUD + "PutUserPolicy": ctrl.PutUserPolicy, + "GetUserPolicy": ctrl.GetUserPolicy, + "DeleteUserPolicy": ctrl.DeleteUserPolicy, + "ListUserPolicies": ctrl.ListUserPolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 3c3b1692..7eef589a 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -21,6 +21,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" @@ -113,6 +114,9 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { if !ok { return nil, iamerr.NoSuchEntityUser(username) } + if len(user.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -445,9 +449,163 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return out, nil } +func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInput) 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 + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return nil, iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Users[input.UserName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName string) (*types.PolicyEntry, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[userName] + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName string) 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 + } + + user, ok := conf.Users[userName] + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + conf.Users[userName] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + user, ok := conf.Users[input.UserName] + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) cloned.AccessKeys = slices.Clone(user.AccessKeys) + cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 7b8eaed9..d799d711 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -29,6 +29,10 @@ import ( // user may hold at once, matching the AWS IAM quota. const MaxAccessKeysPerUser = 2 +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// all of a single IAM user's inline policy documents combined +const MaxInlinePolicyBytesPerUser = 2048 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -86,6 +90,24 @@ type GetAccessKeyLastUsedOutput struct { Region string } +type PutUserPolicyInput struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserPoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -99,6 +121,11 @@ type Storer interface { DeleteAccessKey(ctx context.Context, username, accessKeyID string) error GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) + + PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error + GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) + DeleteUserPolicy(ctx context.Context, userName, policyName string) error + ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c661db9..48399724 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -233,6 +233,9 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if err != nil { return err } + if len(user.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } @@ -553,6 +556,122 @@ func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInp return out, nil } +func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerUser { + return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + user.Policies.Inline[replaceAt].UpdateDate = now + } else { + user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) { + user, err := s.GetUser(ctx, userName) + if err != nil { + return nil, err + } + + for _, p := range user.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName) +} + +func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error { + user, err := s.GetUser(ctx, userName) + if err != nil { + return err + } + + idx := -1 + for i, p := range user.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityUserPolicy(userName, policyName) + } + + user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) + + _, err = s.replaceUser(ctx, *user) + return err +} + +func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(user.Policies.Inline)) + for _, p := range user.Policies.Inline { + names = append(names, p.PolicyName) + } + sort.Strings(names) + + start := 0 + if input.Marker != "" { + start = len(names) + for i, name := range names { + if name == input.Marker { + start = i + 1 + break + } + } + } + names = names[start:] + + limit := len(names) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListUserPoliciesOutput{ + PolicyNames: make([]string, limit), + } + copy(out.PolicyNames, names[:limit]) + if limit < len(names) { + out.IsTruncated = true + out.Marker = out.PolicyNames[limit-1] + } + + return out, nil +} + // deleteByPath permanently removes a secret and all its versions without // checking for existence first. func (s *VaultStore) deleteByPath(username string) error { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go new file mode 100644 index 00000000..a7149e81 --- /dev/null +++ b/iamapi/types/policy.go @@ -0,0 +1,96 @@ +// 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 types + +import ( + "encoding/xml" + "time" +) + +// Policies holds every kind of policy attached to an identity (user, role ...) +// Inline is the only populated field for now +type Policies struct { + Inline []PolicyEntry `json:"inline,omitempty"` +} + +// PolicyEntry is the storage representation of a single inline policy. It +// round-trips through JSON for the internal and Vault storers and is +// never marshaled to XML directly — mirrors AccessKeyEntry. PolicyDocument +// holds the exact bytes submitted by the caller (after validation), not a +// re-serialized form +type PolicyEntry struct { + PolicyName string + PolicyDocument string + CreateDate time.Time + UpdateDate time.Time +} + +type PutUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetUserPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"` + Result GetUserPolicyResult `xml:"GetUserPolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetUserPolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +// GetUserPolicyResult's PolicyDocument must be RFC 3986 percent-encoded by +// the caller before assignment — see iamutil.EncodePolicyDocument. Real +// IAM returns PolicyDocument URL-encoded; xml.Marshal does not do this +// encoding on its own. +type GetUserPolicyResult struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"` + Result ListUserPoliciesResult `xml:"ListUserPoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListUserPoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserPoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type PolicyNameList struct { + Members []string `xml:"member"` +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index 9083ea2b..f8f4a91f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -106,6 +106,7 @@ type User struct { CreateDate time.Time `xml:"CreateDate"` Tags []Tag `xml:"Tags>member,omitempty"` AccessKeys []AccessKeyEntry `xml:"-"` + Policies Policies `xml:"-"` } type Tag struct { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index ac91884e..8186572e 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1233,6 +1233,47 @@ func TestIAMListAccessKeys(ts *TestState) { ts.Run(IAMListAccessKeys_pagination) } +func TestIAMPutUserPolicy(ts *TestState) { + ts.Run(IAMPutUserPolicy_missing_user_name) + ts.Run(IAMPutUserPolicy_missing_policy_name) + ts.Run(IAMPutUserPolicy_missing_policy_document) + ts.Run(IAMPutUserPolicy_invalid_policy_name) + ts.Run(IAMPutUserPolicy_long_policy_name) + ts.Run(IAMPutUserPolicy_non_ascii_policy_document) + ts.Run(IAMPutUserPolicy_non_existing_user) + ts.Run(IAMPutUserPolicy_malformed_policy_document) + ts.Run(IAMPutUserPolicy_principal_not_allowed) + ts.Run(IAMPutUserPolicy_limit_exceeded) + ts.Run(IAMPutUserPolicy_success) + ts.Run(IAMPutUserPolicy_overwrite_updates_existing) +} + +func TestIAMGetUserPolicy(ts *TestState) { + ts.Run(IAMGetUserPolicy_missing_user_name) + ts.Run(IAMGetUserPolicy_missing_policy_name) + ts.Run(IAMGetUserPolicy_non_existing_user) + ts.Run(IAMGetUserPolicy_non_existing_policy) + ts.Run(IAMGetUserPolicy_success) +} + +func TestIAMDeleteUserPolicy(ts *TestState) { + ts.Run(IAMDeleteUserPolicy_missing_user_name) + ts.Run(IAMDeleteUserPolicy_missing_policy_name) + ts.Run(IAMDeleteUserPolicy_non_existing_user) + ts.Run(IAMDeleteUserPolicy_non_existing_policy) + ts.Run(IAMDeleteUserPolicy_success) + ts.Run(IAMDeleteUserPolicy_blocks_user_deletion) +} + +func TestIAMListUserPolicies(ts *TestState) { + ts.Run(IAMListUserPolicies_missing_user_name) + ts.Run(IAMListUserPolicies_non_existing_user) + ts.Run(IAMListUserPolicies_invalid_max_items) + ts.Run(IAMListUserPolicies_empty_result) + ts.Run(IAMListUserPolicies_success) + ts.Run(IAMListUserPolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1246,6 +1287,10 @@ func TestIAM(ts *TestState) { TestIAMDeleteAccessKey(ts) TestIAMGetAccessKeyLastUsed(ts) TestIAMListAccessKeys(ts) + TestIAMPutUserPolicy(ts) + TestIAMGetUserPolicy(ts) + TestIAMDeleteUserPolicy(ts) + TestIAMListUserPolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1674,6 +1719,35 @@ func GetIntTests() IntTests { "IAMListAccessKeys_empty_result": IAMListAccessKeys_empty_result, "IAMListAccessKeys_success": IAMListAccessKeys_success, "IAMListAccessKeys_pagination": IAMListAccessKeys_pagination, + "IAMPutUserPolicy_missing_user_name": IAMPutUserPolicy_missing_user_name, + "IAMPutUserPolicy_missing_policy_name": IAMPutUserPolicy_missing_policy_name, + "IAMPutUserPolicy_missing_policy_document": IAMPutUserPolicy_missing_policy_document, + "IAMPutUserPolicy_invalid_policy_name": IAMPutUserPolicy_invalid_policy_name, + "IAMPutUserPolicy_long_policy_name": IAMPutUserPolicy_long_policy_name, + "IAMPutUserPolicy_non_ascii_policy_document": IAMPutUserPolicy_non_ascii_policy_document, + "IAMPutUserPolicy_non_existing_user": IAMPutUserPolicy_non_existing_user, + "IAMPutUserPolicy_malformed_policy_document": IAMPutUserPolicy_malformed_policy_document, + "IAMPutUserPolicy_principal_not_allowed": IAMPutUserPolicy_principal_not_allowed, + "IAMPutUserPolicy_limit_exceeded": IAMPutUserPolicy_limit_exceeded, + "IAMPutUserPolicy_success": IAMPutUserPolicy_success, + "IAMPutUserPolicy_overwrite_updates_existing": IAMPutUserPolicy_overwrite_updates_existing, + "IAMGetUserPolicy_missing_user_name": IAMGetUserPolicy_missing_user_name, + "IAMGetUserPolicy_missing_policy_name": IAMGetUserPolicy_missing_policy_name, + "IAMGetUserPolicy_non_existing_user": IAMGetUserPolicy_non_existing_user, + "IAMGetUserPolicy_non_existing_policy": IAMGetUserPolicy_non_existing_policy, + "IAMGetUserPolicy_success": IAMGetUserPolicy_success, + "IAMDeleteUserPolicy_missing_user_name": IAMDeleteUserPolicy_missing_user_name, + "IAMDeleteUserPolicy_missing_policy_name": IAMDeleteUserPolicy_missing_policy_name, + "IAMDeleteUserPolicy_non_existing_user": IAMDeleteUserPolicy_non_existing_user, + "IAMDeleteUserPolicy_non_existing_policy": IAMDeleteUserPolicy_non_existing_policy, + "IAMDeleteUserPolicy_success": IAMDeleteUserPolicy_success, + "IAMDeleteUserPolicy_blocks_user_deletion": IAMDeleteUserPolicy_blocks_user_deletion, + "IAMListUserPolicies_missing_user_name": IAMListUserPolicies_missing_user_name, + "IAMListUserPolicies_non_existing_user": IAMListUserPolicies_non_existing_user, + "IAMListUserPolicies_invalid_max_items": IAMListUserPolicies_invalid_max_items, + "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, + "IAMListUserPolicies_success": IAMListUserPolicies_success, + "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_delete_user_policy.go b/tests/integration/iam_delete_user_policy.go new file mode 100644 index 00000000..cf4ac726 --- /dev/null +++ b/tests/integration/iam_delete_user_policy.go @@ -0,0 +1,203 @@ +// 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" + "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 IAMDeleteUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMDeleteUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.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("policyName")) + }) +} + +func IAMDeleteUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMDeleteUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_success(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteUserPolicy response request id") + } + + _, err = getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUserPolicy(userName, "p")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteUserPolicy_blocks_user_deletion(s *S3Conf) error { + testName := "IAMDeleteUserPolicy_blocks_user_deletion" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMUser(client, userName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMUserPolicy(client, userName, "p") + deleteUserErr := deleteIAMUser(client, userName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteUserErr + }) +} + +func deleteIAMUserPolicyRaw(client *iam.Client, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteUserPolicy(ctx, input) +} + +func deleteIAMUserPolicy(client *iam.Client, userName, policyName string) error { + _, err := deleteIAMUserPolicyRaw(client, &iam.DeleteUserPolicyInput{UserName: &userName, PolicyName: &policyName}) + return err +} + +// deleteIAMUserAndPolicies deletes all of the user's inline policies before +// deleting the user, since DeleteUser rejects users with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMUserAndPolicies(client *iam.Client, userName string) error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMUserPolicy(client, userName, policyName); err != nil { + return err + } + } + return deleteIAMUser(client, userName) +} diff --git a/tests/integration/iam_get_user_policy.go b/tests/integration/iam_get_user_policy.go new file mode 100644 index 00000000..b67adb64 --- /dev/null +++ b/tests/integration/iam_get_user_policy.go @@ -0,0 +1,165 @@ +// 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" + "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 IAMGetUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMGetUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + }.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("policyName")) + }) +} + +func IAMGetUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMGetUserPolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetUserPolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityUserPolicy(userName, "missing"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetUserPolicy_success(s *S3Conf) error { + testName := "IAMGetUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetUserPolicy output") + } + if aws.ToString(out.UserName) != userName { + return fmt.Errorf("expected user name %q, instead got %q", userName, aws.ToString(out.UserName)) + } + if aws.ToString(out.PolicyName) != "ReadOnly" { + return fmt.Errorf("expected policy name %q, instead got %q", "ReadOnly", aws.ToString(out.PolicyName)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(out.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(out.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetUserPolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMUserPolicy(client *iam.Client, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetUserPolicy(ctx, input) +} diff --git a/tests/integration/iam_list_user_policies.go b/tests/integration/iam_list_user_policies.go new file mode 100644 index 00000000..c9514ef2 --- /dev/null +++ b/tests/integration/iam_list_user_policies.go @@ -0,0 +1,223 @@ +// 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" + "slices" + "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 IAMListUserPolicies_missing_user_name(s *S3Conf) error { + testName := "IAMListUserPolicies_missing_user_name" + body := []byte("Action=ListUserPolicies&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMListUserPolicies_non_existing_user(s *S3Conf) error { + testName := "IAMListUserPolicies_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListUserPolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListUserPolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_empty_result(s *S3Conf) error { + testName := "IAMListUserPolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if len(out.PolicyNames) != 0 { + return fmt.Errorf("expected no policies, instead got %v", out.PolicyNames) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_success(s *S3Conf) error { + testName := "IAMListUserPolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListUserPolicies response request id") + } + got := slices.Clone(out.PolicyNames) + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserPolicies_pagination(s *S3Conf) error { + testName := "IAMListUserPolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1)} + var pages []*iam.ListUserPoliciesOutput + for { + out, err := listIAMUserPolicies(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.PolicyNames) != 1 { + return fmt.Errorf("expected page %d to contain 1 policy, instead got %d", i+1, len(page.PolicyNames)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, page.PolicyNames...) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected policy names %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMUserPolicies(client *iam.Client, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListUserPolicies(ctx, input) +} diff --git a/tests/integration/iam_put_user_policy.go b/tests/integration/iam_put_user_policy.go new file mode 100644 index 00000000..e47e90a3 --- /dev/null +++ b/tests/integration/iam_put_user_policy.go @@ -0,0 +1,376 @@ +// 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" + "github.com/versity/versitygw/iamapi/storage" +) + +const validIAMPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + +func IAMPutUserPolicy_missing_user_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_user_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "PolicyName": {"p"}, + "PolicyDocument": {validIAMPolicyDocument}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMPutUserPolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyDocument": {validIAMPolicyDocument}, + }.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("policyName")) + }) +} + +func IAMPutUserPolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutUserPolicy"}, + "Version": {"2010-05-08"}, + "UserName": {newIAMUserName()}, + "PolicyName": {"p"}, + }.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("policyDocument")) + }) +} + +func IAMPutUserPolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutUserPolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutUserPolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutUserPolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: aws.String(newIAMUserName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutUserPolicy_non_existing_user(s *S3Conf) error { + testName := "IAMPutUserPolicy_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMPutUserPolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutUserPolicy_malformed_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + cases := []struct { + name string + doc string + wantErr iamerr.APIError + }{ + {"invalid json syntax", `{not valid json`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty object", `{}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"null statement", `{"Version":"2012-10-17","Statement":null}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"statement is a string", `{"Version":"2012-10-17","Statement":"hello"}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"missing effect", `{"Version":"2012-10-17","Statement":[{"Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Action":"s3:GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"action and notaction both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotAction":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"resource and notresource both present", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","NotResource":"foo"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + {"numeric action wrong type", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":123,"Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Syntax errors in policy.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain actions.")}, + + {"missing resource and notresource", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + {"empty resource array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":[]}]}`, iamerr.MalformedPolicyDocument("Policy statement must contain resources.")}, + + {"empty string action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"action missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"notaction missing vendor colon", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Actions/Conditions must be prefaced by a vendor, e.g., iam, sdb, ec2, etc.")}, + {"empty vendor prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":":GetObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor is not valid")}, + {"vendor with invalid character", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam :Get","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Vendor iam is not valid")}, + + {"resource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"notresource with no colon at all", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","NotResource":"invalid"}]}`, iamerr.MalformedPolicyDocument(`Resource invalid must be in ARN format or "*".`)}, + {"resource with colon but no arn prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"s3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "" is not valid for resource "arn::example-bucket/*:*:*:*".`)}, + {"resource with arn prefix but too few fields", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:awss3::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument("The policy failed legacy parsing")}, + {"resource with invalid partition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws2:s3:::example-bucket/*"}]}`, iamerr.MalformedPolicyDocument(`Partition "aws2" is not valid for resource "arn:aws2:s3:::example-bucket/*".`)}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Sid":"Dup","Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Statement IDs (SID) in a single policy must be unique.")}, + } + + for _, c := range cases { + if err := func() error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(c.doc), + }) + if err := checkIAMApiErr(err, c.wantErr); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutUserPolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutUserPolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutUserPolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("user", userName, storage.MaxInlinePolicyBytesPerUser)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_success(s *S3Conf) error { + testName := "IAMPutUserPolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + out, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutUserPolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutUserPolicy response request id") + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != validIAMPolicyDocument { + return fmt.Errorf("expected policy document %q, instead got %q", validIAMPolicyDocument, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutUserPolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutUserPolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + updated := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}` + if _, err := putIAMUserPolicy(client, &iam.PutUserPolicyInput{ + UserName: &userName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMUserPolicy(client, &iam.GetUserPolicyInput{UserName: &userName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.PolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode policy document %q: %w", aws.ToString(got.PolicyDocument), err) + } + if gotDocument != updated { + return fmt.Errorf("expected overwritten policy document %q, instead got %q", updated, gotDocument) + } + return nil + }() + + deleteErr := deleteIAMUserAndPolicies(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMUserPolicy(client *iam.Client, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutUserPolicy(ctx, input) +}