diff --git a/iamapi/controller.go b/iamapi/controller.go index f224d24b..0cd44868 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -718,3 +718,133 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil } + +func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required PutRolePolicy 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 PutRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + // Confirm the role exists before inspecting policy document content + if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to get IAM role %q for PutRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.Parse(policyDocument); err != nil { + return nil, err + } + + if err := c.store.PutRolePolicy(ctx.Context(), storage.PutRolePolicyInput{ + RoleName: roleName, + PolicyName: policyName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to put IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.PutRolePolicyResponse{}}, nil +} + +func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required GetRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + entry, err := c.store.GetRolePolicy(ctx.Context(), roleName, policyName) + if err != nil { + debuglogger.Logf("failed to get IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.GetRolePolicyResponse{ + Result: types.GetRolePolicyResult{ + RoleName: roleName, + PolicyName: entry.PolicyName, + PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument), + }, + }}, nil +} + +func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyName, ok := iamutil.RequestParam(ctx, "PolicyName") + if !ok { + debuglogger.Logf("missing required DeleteRolePolicy parameter: PolicyName") + return nil, iamerr.MissingValue("policyName") + } + if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRolePolicy(ctx.Context(), roleName, policyName); err != nil { + debuglogger.Logf("failed to delete IAM role policy %q for role %q: %v", policyName, roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRolePolicyResponse{}}, nil +} + +func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRolePolicies") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRolePolicies(ctx.Context(), storage.ListRolePoliciesInput{ + RoleName: roleName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM role policies for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.ListRolePoliciesResponse{ + Result: types.ListRolePoliciesResult{ + 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 dd975302..3c0cefa8 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -1301,6 +1301,376 @@ func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { } } +func TestIAMApiControllerRolePolicyLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + createRole := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if createRole.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", createRole.StatusCode, readBody(t, createRole)) + } + + policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}` + + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {policyDoc}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + var putOut iamtypes.PutRolePolicyResponse + unmarshalXML(t, readBody(t, put), &putOut) + if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutRolePolicyResponse" { + t.Fatalf("PutRolePolicy XMLName = %#v", putOut.XMLName) + } + if putOut.ResponseMetadata.RequestID == "" { + t.Fatal("PutRolePolicy missing RequestId") + } + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRolePolicy status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRolePolicyResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.RoleName != "my-role" || getOut.Result.PolicyName != "ReadOnly" { + t.Fatalf("GetRolePolicy result = %#v", getOut.Result) + } + if !strings.Contains(getOut.Result.PolicyDocument, "%20") { + t.Fatalf("GetRolePolicy 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("GetRolePolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRolePolicies"}, + "RoleName": {"my-role"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRolePolicies status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolePoliciesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" { + t.Fatalf("ListRolePolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members) + } + if listOut.Result.IsTruncated { + t.Fatal("ListRolePolicies 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": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`}, + }) + if overwritePut.StatusCode != http.StatusOK { + t.Fatalf("overwrite PutRolePolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut)) + } + overwriteGet := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + var overwriteOut iamtypes.GetRolePolicyResponse + 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("GetRolePolicy after overwrite = %q, want the Deny statement", overwriteDecoded) + } + + del := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + if del.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", del.StatusCode, readBody(t, del)) + } + var delOut iamtypes.DeleteRolePolicyResponse + unmarshalXML(t, readBody(t, del), &delOut) + if delOut.XMLName.Local != "DeleteRolePolicyResponse" || delOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRolePolicy output = %#v", delOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role 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": {"DeleteRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"ReadOnly"}, + }) + requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.") +} + +func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) { + validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + + tests := []struct { + name string + setupRole bool + params url.Values + status int + code string + message string + }{ + { + name: "put missing policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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 role name", + params: url.Values{"Action": {"PutRolePolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "put invalid policy name characters", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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 role does not exist", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put nonexistent role wins over malformed document", + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "put malformed policy document", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}}, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Syntax errors in policy.", + }, + { + name: "put policy document with principal", + setupRole: true, + params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "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: "get role does not exist", + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "get policy does not exist", + setupRole: true, + params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "delete role does not exist", + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "delete policy does not exist", + setupRole: true, + params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role policy with name NoSuchPolicy cannot be found.", + }, + { + name: "list role does not exist", + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"nonexistent"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nonexistent cannot be found.", + }, + { + name: "list max items too large", + setupRole: true, + params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "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.setupRole { + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateRole 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 TestIAMApiControllerDeleteRolePolicyConflict(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + put := doIAMAction(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}, + }) + if put.StatusCode != http.StatusOK { + t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put)) + } + + deleteRole := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + requireIAMError(t, deleteRole, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.") + + delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}}) + if delPolicy.StatusCode != http.StatusOK { + t.Fatalf("DeleteRolePolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy)) + } + + deleteRoleAfter := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}}) + if deleteRoleAfter.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteRoleAfter.StatusCode, readBody(t, deleteRoleAfter)) + } +} + +func TestIAMApiControllerPutRolePolicyOversizedDocument(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": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "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 TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { + // The role's aggregate inline-policy quota (10240 bytes) is well over + // this test server's GET header/URL read-buffer limit, so this case + // is exercised over POST, same as TestIAMApiControllerPutRolePolicyOversizedDocument. + server := newIAMControllerTestServer(t) + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + resp := doIAMActionPost(t, server, url.Values{ + "Action": {"PutRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyName": {"P"}, + "PolicyDocument": {oversizedDoc}, + }) + requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 4c919a0e..1df3877e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -453,6 +453,10 @@ func NoSuchEntityUserPolicy(userName, policyName string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound) } +func NoSuchEntityRolePolicy(roleName, policyName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role 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) } diff --git a/iamapi/router.go b/iamapi/router.go index 4e6444e9..ddb39bd2 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -68,6 +68,11 @@ func (r *IAMApiRouter) Init() { "ListRoles": ctrl.ListRoles, "DeleteRole": ctrl.DeleteRole, "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + // Role Inline Policy CRUD + "PutRolePolicy": ctrl.PutRolePolicy, + "GetRolePolicy": ctrl.GetRolePolicy, + "DeleteRolePolicy": ctrl.DeleteRolePolicy, + "ListRolePolicies": ctrl.ListRolePolicies, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 81a76a61..536418d8 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -816,6 +816,159 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs return cloneRole(updated), nil } +func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + now := time.Now().UTC().Truncate(time.Second) + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return nil, iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) GetRolePolicy(_ context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *InternalStore) DeleteRolePolicy(_ context.Context, roleName, 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 + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListRolePolicies(_ context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.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 := &ListRolePoliciesOutput{ + 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) diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index f862d00f..aa915c19 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -33,6 +33,10 @@ const MaxAccessKeysPerUser = 2 // all of a single IAM user's inline policy documents combined const MaxInlinePolicyBytesPerUser = 2048 +// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of +// all of a single IAM role's inline policy documents combined +const MaxInlinePolicyBytesPerRole = 10240 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -126,6 +130,24 @@ type UpdateAssumeRolePolicyInput struct { PolicyDocument string } +type PutRolePolicyInput struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListRolePoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -150,6 +172,11 @@ type Storer interface { ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) DeleteRole(ctx context.Context, roleName string) error UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) + + PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error + GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) + DeleteRolePolicy(ctx context.Context, roleName, policyName string) error + ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index e54104a0..59c95a56 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -384,3 +384,130 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreRolePolicyCRUD(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateRole(ctx, types.Role{ + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "ALICE-ROLE", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy: %v", err) + } + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "missing-role", PolicyName: "P", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("PutRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + entry, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy: %v", err) + } + if entry.PolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` { + t.Fatalf("GetRolePolicy document = %q", entry.PolicyDocument) + } + if entry.CreateDate.IsZero() || entry.UpdateDate.IsZero() { + t.Fatalf("GetRolePolicy CreateDate/UpdateDate zero: %#v", entry) + } + if _, err := store.GetRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("GetRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if _, err := store.GetRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("GetRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + // Overwriting an existing PolicyName replaces its document rather than + // stacking toward the aggregate size quota. + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "ReadOnly", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("overwrite PutRolePolicy: %v", err) + } + overwritten, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly") + if err != nil { + t.Fatalf("GetRolePolicy after overwrite: %v", err) + } + if !strings.Contains(overwritten.PolicyDocument, "Deny") { + t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwritten.PolicyDocument) + } + + // Aggregate inline policy size for a role is capped at + // MaxInlinePolicyBytesPerRole (10240), distinct from and larger than + // the 2048 byte cap for users. + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "alice-role", PolicyName: "TooBig", PolicyDocument: oversized}); !errors.Is(err, iamerr.InlinePolicyQuotaExceeded("role", "alice-role", MaxInlinePolicyBytesPerRole)) { + t.Fatalf("PutRolePolicy oversized err = %v, want LimitExceeded", err) + } + + if err := store.PutRolePolicy(ctx, PutRolePolicyInput{ + RoleName: "alice-role", + PolicyName: "SecondPolicy", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`, + }); err != nil { + t.Fatalf("PutRolePolicy second policy: %v", err) + } + + list, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "ALICE-ROLE", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRolePolicies page1: %v", err) + } + if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "ReadOnly" || !list.IsTruncated || list.Marker != "ReadOnly" { + t.Fatalf("ListRolePolicies page1 = %#v, want truncated ReadOnly page", list) + } + page2, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "alice-role", Marker: list.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRolePolicies page2: %v", err) + } + if len(page2.PolicyNames) != 1 || page2.PolicyNames[0] != "SecondPolicy" || page2.IsTruncated { + t.Fatalf("ListRolePolicies page2 = %#v, want final SecondPolicy page", page2) + } + if _, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "missing-role"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("ListRolePolicies missing role err = %v, want NoSuchEntity", err) + } + + // A role with attached inline policies cannot be deleted until they are + // all removed first. + if err := store.DeleteRole(ctx, "alice-role"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) { + t.Fatalf("DeleteRole with policies err = %v, want DeleteConflict", err) + } + + if err := store.DeleteRolePolicy(ctx, "alice-role", "SecondPolicy"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := store.DeleteRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) { + t.Fatalf("DeleteRolePolicy missing policy err = %v, want NoSuchEntity", err) + } + if err := store.DeleteRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("DeleteRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + if _, err := reopened.GetRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("GetRolePolicy after reopen: %v", err) + } + + if err := reopened.DeleteRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil { + t.Fatalf("DeleteRolePolicy: %v", err) + } + if err := reopened.DeleteRole(ctx, "alice-role"); err != nil { + t.Fatalf("DeleteRole after removing all policies: %v", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index d54c97a2..9c914d85 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -940,6 +940,122 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss return s.replaceRole(ctx, *role) } +func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return err + } + + newTotal := len(input.PolicyDocument) + replaceAt := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == input.PolicyName { + replaceAt = i + continue + } + newTotal += len(p.PolicyDocument) + } + if newTotal > MaxInlinePolicyBytesPerRole { + return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole) + } + + now := time.Now().UTC().Truncate(time.Second) + if replaceAt >= 0 { + role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument + role.Policies.Inline[replaceAt].UpdateDate = now + } else { + role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{ + PolicyName: input.PolicyName, + PolicyDocument: input.PolicyDocument, + CreateDate: now, + UpdateDate: now, + }) + } + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return nil, err + } + + for _, p := range role.Policies.Inline { + if p.PolicyName == policyName { + cloned := p + return &cloned, nil + } + } + + return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName) +} + +func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + + idx := -1 + for i, p := range role.Policies.Inline { + if p.PolicyName == policyName { + idx = i + break + } + } + if idx == -1 { + return iamerr.NoSuchEntityRolePolicy(roleName, policyName) + } + + role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1) + + _, err = s.replaceRole(ctx, *role) + return err +} + +func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(role.Policies.Inline)) + for _, p := range role.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 := &ListRolePoliciesOutput{ + 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 +} + // replaceRole overwrites the stored document for role.RoleName by deleting // all existing versions and recreating with CAS=0. func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) { diff --git a/iamapi/types/policy.go b/iamapi/types/policy.go index a7149e81..863084ec 100644 --- a/iamapi/types/policy.go +++ b/iamapi/types/policy.go @@ -94,3 +94,53 @@ type ListUserPoliciesResult struct { type PolicyNameList struct { Members []string `xml:"member"` } + +type PutRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *PutRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type DeleteRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRolePolicyResponse"` + Result GetRolePolicyResult `xml:"GetRolePolicyResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRolePolicyResult struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolePoliciesResponse"` + Result ListRolePoliciesResult `xml:"ListRolePoliciesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolePoliciesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolePoliciesResult struct { + PolicyNames PolicyNameList + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 77a58301..5d08f875 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1322,6 +1322,7 @@ func TestIAMDeleteRole(ts *TestState) { ts.Run(IAMDeleteRole_invalid_role_name) ts.Run(IAMDeleteRole_long_role_name) ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_has_policies) ts.Run(IAMDeleteRole_success) } @@ -1337,6 +1338,47 @@ func TestIAMUpdateAssumeRolePolicy(ts *TestState) { ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) } +func TestIAMPutRolePolicy(ts *TestState) { + ts.Run(IAMPutRolePolicy_missing_role_name) + ts.Run(IAMPutRolePolicy_missing_policy_name) + ts.Run(IAMPutRolePolicy_missing_policy_document) + ts.Run(IAMPutRolePolicy_invalid_policy_name) + ts.Run(IAMPutRolePolicy_long_policy_name) + ts.Run(IAMPutRolePolicy_non_ascii_policy_document) + ts.Run(IAMPutRolePolicy_non_existing_role) + ts.Run(IAMPutRolePolicy_malformed_policy_document) + ts.Run(IAMPutRolePolicy_principal_not_allowed) + ts.Run(IAMPutRolePolicy_limit_exceeded) + ts.Run(IAMPutRolePolicy_success) + ts.Run(IAMPutRolePolicy_overwrite_updates_existing) +} + +func TestIAMGetRolePolicy(ts *TestState) { + ts.Run(IAMGetRolePolicy_missing_role_name) + ts.Run(IAMGetRolePolicy_missing_policy_name) + ts.Run(IAMGetRolePolicy_non_existing_role) + ts.Run(IAMGetRolePolicy_non_existing_policy) + ts.Run(IAMGetRolePolicy_success) +} + +func TestIAMDeleteRolePolicy(ts *TestState) { + ts.Run(IAMDeleteRolePolicy_missing_role_name) + ts.Run(IAMDeleteRolePolicy_missing_policy_name) + ts.Run(IAMDeleteRolePolicy_non_existing_role) + ts.Run(IAMDeleteRolePolicy_non_existing_policy) + ts.Run(IAMDeleteRolePolicy_success) + ts.Run(IAMDeleteRolePolicy_blocks_role_deletion) +} + +func TestIAMListRolePolicies(ts *TestState) { + ts.Run(IAMListRolePolicies_missing_role_name) + ts.Run(IAMListRolePolicies_non_existing_role) + ts.Run(IAMListRolePolicies_invalid_max_items) + ts.Run(IAMListRolePolicies_empty_result) + ts.Run(IAMListRolePolicies_success) + ts.Run(IAMListRolePolicies_pagination) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1359,6 +1401,10 @@ func TestIAM(ts *TestState) { TestIAMListRoles(ts) TestIAMDeleteRole(ts) TestIAMUpdateAssumeRolePolicy(ts) + TestIAMPutRolePolicy(ts) + TestIAMGetRolePolicy(ts) + TestIAMDeleteRolePolicy(ts) + TestIAMListRolePolicies(ts) } func TestAccessControl(ts *TestState) { @@ -1854,6 +1900,7 @@ func GetIntTests() IntTests { "IAMDeleteRole_invalid_role_name": IAMDeleteRole_invalid_role_name, "IAMDeleteRole_long_role_name": IAMDeleteRole_long_role_name, "IAMDeleteRole_non_existing_role": IAMDeleteRole_non_existing_role, + "IAMDeleteRole_has_policies": IAMDeleteRole_has_policies, "IAMDeleteRole_success": IAMDeleteRole_success, "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, @@ -1864,6 +1911,35 @@ func GetIntTests() IntTests { "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, + "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, + "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, + "IAMPutRolePolicy_invalid_policy_name": IAMPutRolePolicy_invalid_policy_name, + "IAMPutRolePolicy_long_policy_name": IAMPutRolePolicy_long_policy_name, + "IAMPutRolePolicy_non_ascii_policy_document": IAMPutRolePolicy_non_ascii_policy_document, + "IAMPutRolePolicy_non_existing_role": IAMPutRolePolicy_non_existing_role, + "IAMPutRolePolicy_malformed_policy_document": IAMPutRolePolicy_malformed_policy_document, + "IAMPutRolePolicy_principal_not_allowed": IAMPutRolePolicy_principal_not_allowed, + "IAMPutRolePolicy_limit_exceeded": IAMPutRolePolicy_limit_exceeded, + "IAMPutRolePolicy_success": IAMPutRolePolicy_success, + "IAMPutRolePolicy_overwrite_updates_existing": IAMPutRolePolicy_overwrite_updates_existing, + "IAMGetRolePolicy_missing_role_name": IAMGetRolePolicy_missing_role_name, + "IAMGetRolePolicy_missing_policy_name": IAMGetRolePolicy_missing_policy_name, + "IAMGetRolePolicy_non_existing_role": IAMGetRolePolicy_non_existing_role, + "IAMGetRolePolicy_non_existing_policy": IAMGetRolePolicy_non_existing_policy, + "IAMGetRolePolicy_success": IAMGetRolePolicy_success, + "IAMDeleteRolePolicy_missing_role_name": IAMDeleteRolePolicy_missing_role_name, + "IAMDeleteRolePolicy_missing_policy_name": IAMDeleteRolePolicy_missing_policy_name, + "IAMDeleteRolePolicy_non_existing_role": IAMDeleteRolePolicy_non_existing_role, + "IAMDeleteRolePolicy_non_existing_policy": IAMDeleteRolePolicy_non_existing_policy, + "IAMDeleteRolePolicy_success": IAMDeleteRolePolicy_success, + "IAMDeleteRolePolicy_blocks_role_deletion": IAMDeleteRolePolicy_blocks_role_deletion, + "IAMListRolePolicies_missing_role_name": IAMListRolePolicies_missing_role_name, + "IAMListRolePolicies_non_existing_role": IAMListRolePolicies_non_existing_role, + "IAMListRolePolicies_invalid_max_items": IAMListRolePolicies_invalid_max_items, + "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, + "IAMListRolePolicies_success": IAMListRolePolicies_success, + "IAMListRolePolicies_pagination": IAMListRolePolicies_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_role.go b/tests/integration/iam_delete_role.go index ecb30031..a0e5c57a 100644 --- a/tests/integration/iam_delete_role.go +++ b/tests/integration/iam_delete_role.go @@ -67,6 +67,39 @@ func IAMDeleteRole_non_existing_role(s *S3Conf) error { }) } +func IAMDeleteRole_has_policies(s *S3Conf) error { + testName := "IAMDeleteRole_has_policies" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + func IAMDeleteRole_success(s *S3Conf) error { testName := "IAMDeleteRole_success" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role_policy.go b/tests/integration/iam_delete_role_policy.go new file mode 100644 index 00000000..b0277cb8 --- /dev/null +++ b/tests/integration/iam_delete_role_policy.go @@ -0,0 +1,212 @@ +// 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 IAMDeleteRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "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("roleName")) + }) +} + +func IAMDeleteRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"DeleteRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 IAMDeleteRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_success(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected DeleteRolePolicy response request id") + } + + _, err = getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("p")}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRolePolicy(roleName, "p")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMDeleteRolePolicy_blocks_role_deletion(s *S3Conf) error { + testName := "IAMDeleteRolePolicy_blocks_role_deletion" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr(deleteIAMRole(client, roleName), iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) + + deletePolicyErr := deleteIAMRolePolicy(client, roleName, "p") + deleteRoleErr := deleteIAMRole(client, roleName) + + if checkErr != nil { + return checkErr + } + if deletePolicyErr != nil { + return deletePolicyErr + } + return deleteRoleErr + }) +} + +func deleteIAMRolePolicyRaw(client *iam.Client, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.DeleteRolePolicy(ctx, input) +} + +func deleteIAMRolePolicy(client *iam.Client, roleName, policyName string) error { + _, err := deleteIAMRolePolicyRaw(client, &iam.DeleteRolePolicyInput{RoleName: &roleName, PolicyName: &policyName}) + return err +} + +// deleteIAMRoleAndPolicies deletes all of the role's inline policies before +// deleting the role, since DeleteRole rejects roles with policies still +// attached. Use this for test cleanup after a test has created inline +// policies. +func deleteIAMRoleAndPolicies(client *iam.Client, roleName string) error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + for _, policyName := range out.PolicyNames { + if err := deleteIAMRolePolicy(client, roleName, policyName); err != nil { + return err + } + } + return deleteIAMRole(client, roleName) +} diff --git a/tests/integration/iam_get_role_policy.go b/tests/integration/iam_get_role_policy.go new file mode 100644 index 00000000..29ab2826 --- /dev/null +++ b/tests/integration/iam_get_role_policy.go @@ -0,0 +1,171 @@ +// 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 IAMGetRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "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("roleName")) + }) +} + +func IAMGetRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMGetRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"GetRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + }.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 IAMGetRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRolePolicy_non_existing_policy(s *S3Conf) error { + testName := "IAMGetRolePolicy_non_existing_policy" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("missing")}) + return err + }(), + iamerr.NoSuchEntityRolePolicy(roleName, "missing"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMGetRolePolicy_success(s *S3Conf) error { + testName := "IAMGetRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + + out, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, PolicyName: aws.String("ReadOnly")}) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected GetRolePolicy output") + } + if aws.ToString(out.RoleName) != roleName { + return fmt.Errorf("expected role name %q, instead got %q", roleName, aws.ToString(out.RoleName)) + } + 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 GetRolePolicy response request id") + } + return nil + }() + + deleteErr := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRolePolicy(client *iam.Client, input *iam.GetRolePolicyInput) (*iam.GetRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRolePolicy(ctx, input) +} diff --git a/tests/integration/iam_list_role_policies.go b/tests/integration/iam_list_role_policies.go new file mode 100644 index 00000000..bbc68da8 --- /dev/null +++ b/tests/integration/iam_list_role_policies.go @@ -0,0 +1,235 @@ +// 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 IAMListRolePolicies_missing_role_name(s *S3Conf) error { + testName := "IAMListRolePolicies_missing_role_name" + body := []byte("Action=ListRolePolicies&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("roleName")) + }) +} + +func IAMListRolePolicies_non_existing_role(s *S3Conf) error { + testName := "IAMListRolePolicies_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMListRolePolicies_invalid_max_items(s *S3Conf) error { + testName := "IAMListRolePolicies_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := checkIAMApiErr( + func() error { + _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)}) + return err + }(), + iamerr.InvalidMaxItems("1001"), + ) + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_empty_result(s *S3Conf) error { + testName := "IAMListRolePolicies_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + 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 := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_success(s *S3Conf) error { + testName := "IAMListRolePolicies_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + out, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRolePolicies 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 := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRolePolicies_pagination(s *S3Conf) error { + testName := "IAMListRolePolicies_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + want := []string{"Alpha", "Beta", "Gamma"} + for _, name := range want { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String(name), + PolicyDocument: aws.String(validIAMPolicyDocument), + }); err != nil { + return err + } + } + + input := iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1)} + var pages []*iam.ListRolePoliciesOutput + for { + out, err := listIAMRolePolicies(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 := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMRolePolicies(client *iam.Client, input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRolePolicies(ctx, input) +} diff --git a/tests/integration/iam_put_role_policy.go b/tests/integration/iam_put_role_policy.go new file mode 100644 index 00000000..89ba286d --- /dev/null +++ b/tests/integration/iam_put_role_policy.go @@ -0,0 +1,389 @@ +// 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" +) + +func IAMPutRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "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("roleName")) + }) +} + +func IAMPutRolePolicy_missing_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_name" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "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 IAMPutRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"PutRolePolicy"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "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 IAMPutRolePolicy_invalid_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_invalid_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("bad/name"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("policyName")) + }) +} + +func IAMPutRolePolicy_long_policy_name(s *S3Conf) error { + testName := "IAMPutRolePolicy_long_policy_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String(strings.Repeat("p", 129)), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("policyName", 128)) + }) +} + +func IAMPutRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: aws.String(newIAMRoleName()), + PolicyName: aws.String("p"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMPutRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMPutRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMPutRolePolicy_malformed_policy_document(s *S3Conf) error { + testName := "IAMPutRolePolicy_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 { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("%s: %w", c.name, err) + } + + checkErr := func() error { + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + 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 := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }(); err != nil { + return err + } + } + + return nil + }) +} + +func IAMPutRolePolicy_principal_not_allowed(s *S3Conf) error { + testName := "IAMPutRolePolicy_principal_not_allowed" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(doc), + }) + return checkIAMApiErr(err, iamerr.MalformedPolicyDocument("Policy document should not specify a principal.")) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_limit_exceeded(s *S3Conf) error { + testName := "IAMPutRolePolicy_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10500) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` + _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.InlinePolicyQuotaExceeded("role", roleName, storage.MaxInlinePolicyBytesPerRole)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_success(s *S3Conf) error { + testName := "IAMPutRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + out, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("ReadOnly"), + PolicyDocument: aws.String(validIAMPolicyDocument), + }) + checkErr := func() error { + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected PutRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected PutRolePolicy response request id") + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, 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 := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMPutRolePolicy_overwrite_updates_existing(s *S3Conf) error { + testName := "IAMPutRolePolicy_overwrite_updates_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + 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 := putIAMRolePolicy(client, &iam.PutRolePolicyInput{ + RoleName: &roleName, + PolicyName: aws.String("p"), + PolicyDocument: aws.String(updated), + }); err != nil { + return err + } + + got, err := getIAMRolePolicy(client, &iam.GetRolePolicyInput{RoleName: &roleName, 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 := deleteIAMRoleAndPolicies(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func putIAMRolePolicy(client *iam.Client, input *iam.PutRolePolicyInput) (*iam.PutRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.PutRolePolicy(ctx, input) +}